{"TotalCount":302,"Files":[{"Ident":"pooh.geppetto","Path":"Editor/Effigy/MeshSection.cs","FileName":"MeshSection.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// Where a mesh meets a plane, as line segments.\n///\n/// This exists for one job: showing the FOOTPRINT another body leaves on a face. Effigy does not\n/// union bodies, so an extrude standing on a slab is two separate solids that happen to overlap,\n/// and the slab\u0027s top face is one uninterrupted rectangle with no idea anything is sitting on it.\n/// Outlining only that rectangle makes a face with a block on it look completely clear.\n///\n/// Two cases, and both turn up constantly because they are the two ways bodies get stacked:\n/// a solid that PASSES THROUGH the plane, whose faces cross it and produce a chord each; and one\n/// that SITS ON it, whose bottom face lies in the plane and produces its own outline. Handling\n/// only the first would miss every part built by sketching on a face, which is most of them.\n/// \u003C/summary\u003E\npublic static class MeshSection\n{\n\t/// \u003Csummary\u003E\n\t/// Every segment where \u003Cparamref name=\u0022mesh\u0022/\u003E meets the plane through\n\t/// \u003Cparamref name=\u0022planeOrigin\u0022/\u003E with \u003Cparamref name=\u0022planeNormal\u0022/\u003E.\n\t/// \u003C/summary\u003E\n\tpublic static List\u003C(Vec3 A, Vec3 B)\u003E CrossSection( PolyMesh mesh, Vec3 planeOrigin, Vec3 planeNormal,\n\t\tfloat tolerance = 1e-4f )\n\t{\n\t\tvar segments = new List\u003C(Vec3, Vec3)\u003E();\n\n\t\tif ( mesh is null || mesh.Faces.Count == 0 )\n\t\t\treturn segments;\n\n\t\tvar normal = planeNormal.Normal;\n\n\t\tforeach ( var face in mesh.Faces )\n\t\t{\n\t\t\tif ( face.Count \u003C 3 )\n\t\t\t\tcontinue;\n\n\t\t\tvar distances = new float[face.Count];\n\t\t\tvar above = false;\n\t\t\tvar below = false;\n\n\t\t\tfor ( var i = 0; i \u003C face.Count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tdistances[i] = Vec3.Dot( mesh.Positions[face.Indices[i]] - planeOrigin, normal );\n\n\t\t\t\tif ( distances[i] \u003E tolerance )\n\t\t\t\t\tabove = true;\n\t\t\t\telse if ( distances[i] \u003C -tolerance )\n\t\t\t\t\tbelow = true;\n\t\t\t}\n\n\t\t\tvar coplanar = !above \u0026\u0026 !below;\n\n\t\t\t// Sitting flat ON the plane: the face\u0027s own edges ARE the footprint.\n\t\t\tif ( coplanar )\n\t\t\t{\n\t\t\t\tfor ( var i = 0; i \u003C face.Count; i\u002B\u002B )\n\t\t\t\t{\n\t\t\t\t\tsegments.Add( (mesh.Positions[face.Indices[i]],\n\t\t\t\t\t\tmesh.Positions[face.Indices[(i \u002B 1) % face.Count]]) );\n\t\t\t\t}\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// STRICTLY BOTH SIDES, or this face does not cross the plane - it only touches it.\n\t\t\t// A block standing on a slab touches with all four of its side walls, each along its\n\t\t\t// bottom edge, and every one of those edges is already an edge of the coplanar bottom\n\t\t\t// face. Without this the whole footprint gets drawn twice.\n\t\t\tif ( !above || !below )\n\t\t\t\tcontinue;\n\n\t\t\t// Passing through it: collect where the face\u0027s edges cross, which for a convex face is\n\t\t\t// exactly two points and therefore one chord.\n\t\t\tvar crossings = new List\u003CVec3\u003E( 2 );\n\n\t\t\tfor ( var i = 0; i \u003C face.Count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar a = mesh.Positions[face.Indices[i]];\n\t\t\t\tvar b = mesh.Positions[face.Indices[(i \u002B 1) % face.Count]];\n\t\t\t\tvar da = distances[i];\n\t\t\t\tvar db = distances[(i \u002B 1) % face.Count];\n\n\t\t\t\t// A corner sitting on the plane is reported once, by the edge that arrives at it -\n\t\t\t\t// counting it twice would leave a zero-length segment behind.\n\t\t\t\tif ( MathF.Abs( da ) \u003C= tolerance )\n\t\t\t\t{\n\t\t\t\t\tcrossings.Add( a );\n\t\t\t\t\tcontinue;\n\t\t\t\t}\n\n\t\t\t\tif ( MathF.Abs( db ) \u003C= tolerance || da * db \u003E 0f )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tcrossings.Add( a \u002B (b - a) * (da / (da - db)) );\n\t\t\t}\n\n\t\t\tfor ( var i = 0; i \u002B 1 \u003C crossings.Count; i \u002B= 2 )\n\t\t\t{\n\t\t\t\tif ( (crossings[i \u002B 1] - crossings[i]).LengthSquared \u003E tolerance * tolerance )\n\t\t\t\t\tsegments.Add( (crossings[i], crossings[i \u002B 1]) );\n\t\t\t}\n\t\t}\n\n\t\treturn segments;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Editor/Effigy/ObjWriter.cs","FileName":"ObjWriter.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Globalization;\nusing System.IO;\nusing System.Text;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// Wavefront OBJ export \u2014 the DEBUG and interchange format, not the export path.\n///\n/// THIS IS NO LONGER HOW MODELS REACH s\u0026box. OBJ cannot carry bones or vertex weights, so it stops\n/// being viable the moment a model is rigged, and rigging is in scope. SmdWriter is the export\n/// path for both static and skinned models; see the note at the top of it for why one format\n/// covers both.\n///\n/// What OBJ is still the best tool for, and why it stays:\n///\n///   It is the only format here that PRESERVES QUADS. SMD triangulates on the way out, so an OBJ is\n///   the only way to look at the cage as the kernel actually holds it \u2014 which is exactly what you\n///   need when checking whether a subdivision result is right. Open it in Blender and the topology\n///   is there to read.\n///\n///   The test suite writes one per primitive for that reason, and round-trips them to prove the\n///   writer emits something parseable with its topology intact.\n///\n/// Godot needs neither path \u2014 there the kernel hands vertices to ArrayMesh directly.\n/// \u003C/summary\u003E\npublic static class ObjWriter\n{\n\t/// \u003Csummary\u003EKept as an alias so existing callers do not have to know where this moved to.\u003C/summary\u003E\n\tpublic const float DefaultSmoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees;\n\n\t/// \u003Csummary\u003EWhat an unnamed slot is called. Shared with SmdWriter and DmxWriter so a model\n\t/// exported three ways names its materials the same three times.\u003C/summary\u003E\n\tpublic static string DefaultMaterialName( int slot ) =\u003E $\u0022material_{slot}\u0022;\n\n\tpublic static void WriteFile( PolyMesh mesh, string path, string objectName = \u0022model\u0022,\n\t\tfloat smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func\u003Cint, string\u003E materialName = null )\n\t{\n\t\tFile.WriteAllText( path, Write( mesh, objectName, smoothingAngleDegrees, materialName ) );\n\t}\n\n\t/// \u003Cparam name=\u0022materialName\u0022\u003EWhat to call each material slot. Defaults to material_0, material_1\n\t/// and so on \u2014 a name a person chose is the difference between binding by meaning and binding by\n\t/// number in whatever the model lands in.\u003C/param\u003E\n\tpublic static string Write( PolyMesh mesh, string objectName = \u0022model\u0022,\n\t\tfloat smoothingAngleDegrees = DefaultSmoothingAngleDegrees, Func\u003Cint, string\u003E materialName = null )\n\t{\n\t\tvar sb = new StringBuilder();\n\t\tvar c = CultureInfo.InvariantCulture;\n\n\t\tsb.Append( \u0022# generated by Effigy\\n\u0022 );\n\t\tsb.Append( $\u0022# {mesh.VertexCount} vertices, {mesh.FaceCount} faces\\n\u0022 );\n\t\tsb.Append( $\u0022o {objectName}\\n\u0022 );\n\n\t\tforeach ( var p in mesh.Positions )\n\t\t\tsb.Append( string.Format( c, \u0022v {0:0.######} {1:0.######} {2:0.######}\\n\u0022, p.x, p.y, p.z ) );\n\n\t\t// UVs are per corner, so the same value recurs constantly. Deduping keeps the file to a\n\t\t// sane size without changing what it means.\n\t\tvar uvIndex = new Dictionary\u003C(long, long), int\u003E();\n\t\tvar faceUVRefs = new int[mesh.FaceCount][];\n\n\t\tfor ( var fi = 0; fi \u003C mesh.FaceCount; fi\u002B\u002B )\n\t\t{\n\t\t\tvar f = mesh.Faces[fi];\n\t\t\tfaceUVRefs[fi] = new int[f.Count];\n\n\t\t\tfor ( var i = 0; i \u003C f.Count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar uv = f.UVs[i];\n\t\t\t\tvar key = ((long)MathF.Round( uv.x * 1e5f ), (long)MathF.Round( uv.y * 1e5f ));\n\n\t\t\t\tif ( !uvIndex.TryGetValue( key, out var idx ) )\n\t\t\t\t{\n\t\t\t\t\tidx = uvIndex.Count;\n\t\t\t\t\tuvIndex[key] = idx;\n\t\t\t\t\tsb.Append( string.Format( c, \u0022vt {0:0.######} {1:0.######}\\n\u0022, uv.x, uv.y ) );\n\t\t\t\t}\n\n\t\t\t\tfaceUVRefs[fi][i] = idx;\n\t\t\t}\n\t\t}\n\n\t\tvar (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );\n\n\t\tforeach ( var n in normals )\n\t\t\tsb.Append( string.Format( c, \u0022vn {0:0.######} {1:0.######} {2:0.######}\\n\u0022, n.x, n.y, n.z ) );\n\n\t\tvar currentMaterial = int.MinValue;\n\n\t\tfor ( var fi = 0; fi \u003C mesh.FaceCount; fi\u002B\u002B )\n\t\t{\n\t\t\tvar f = mesh.Faces[fi];\n\n\t\t\tif ( f.Material != currentMaterial )\n\t\t\t{\n\t\t\t\tcurrentMaterial = f.Material;\n\t\t\t\tsb.Append( $\u0022usemtl {(materialName ?? DefaultMaterialName)( currentMaterial )}\\n\u0022 );\n\t\t\t}\n\n\t\t\tsb.Append( \u0027f\u0027 );\n\n\t\t\tfor ( var i = 0; i \u003C f.Count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\t// OBJ indices are 1-based.\n\t\t\t\tsb.Append( $\u0022 {f.Indices[i] \u002B 1}/{faceUVRefs[fi][i] \u002B 1}/{cornerNormals[fi][i] \u002B 1}\u0022 );\n\t\t\t}\n\n\t\t\tsb.Append( \u0027\\n\u0027 );\n\t\t}\n\n\t\treturn sb.ToString();\n\t}\n\n}\n\n/// \u003Csummary\u003E\n/// Minimal OBJ reader, for round-tripping in tests. Not a general importer \u2014 it understands only\n/// what ObjWriter emits, which is exactly enough to prove the writer produces something parseable\n/// with the counts and topology intact.\n/// \u003C/summary\u003E\npublic static class ObjReader\n{\n\tpublic static PolyMesh Read( string text )\n\t{\n\t\tvar mesh = new PolyMesh();\n\t\tvar uvs = new List\u003CVec2\u003E();\n\t\tvar c = CultureInfo.InvariantCulture;\n\n\t\tforeach ( var raw in text.Split( \u0027\\n\u0027 ) )\n\t\t{\n\t\t\tvar line = raw.Trim();\n\n\t\t\tif ( line.Length == 0 || line[0] == \u0027#\u0027 )\n\t\t\t\tcontinue;\n\n\t\t\tvar parts = line.Split( \u0027 \u0027, StringSplitOptions.RemoveEmptyEntries );\n\n\t\t\tswitch ( parts[0] )\n\t\t\t{\n\t\t\t\tcase \u0022v\u0022:\n\t\t\t\t\tmesh.AddVertex( new Vec3(\n\t\t\t\t\t\tfloat.Parse( parts[1], c ),\n\t\t\t\t\t\tfloat.Parse( parts[2], c ),\n\t\t\t\t\t\tfloat.Parse( parts[3], c ) ) );\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase \u0022vt\u0022:\n\t\t\t\t\tuvs.Add( new Vec2( float.Parse( parts[1], c ), float.Parse( parts[2], c ) ) );\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase \u0022f\u0022:\n\t\t\t\t{\n\t\t\t\t\tvar n = parts.Length - 1;\n\t\t\t\t\tvar indices = new int[n];\n\t\t\t\t\tvar faceUVs = new Vec2[n];\n\n\t\t\t\t\tfor ( var i = 0; i \u003C n; i\u002B\u002B )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar refs = parts[i \u002B 1].Split( \u0027/\u0027 );\n\t\t\t\t\t\tindices[i] = int.Parse( refs[0], c ) - 1;\n\n\t\t\t\t\t\tif ( refs.Length \u003E 1 \u0026\u0026 refs[1].Length \u003E 0 )\n\t\t\t\t\t\t\tfaceUVs[i] = uvs[int.Parse( refs[1], c ) - 1];\n\t\t\t\t\t}\n\n\t\t\t\t\tmesh.AddFace( indices, faceUVs );\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn mesh;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Editor/Effigy/DmxAnimWriter.cs","FileName":"DmxAnimWriter.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using System;\nusing System.IO;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// DMX export \u2014 the animation path. A skeleton and a set of per-bone channels, which is what\n/// ModelDoc\u0027s \u0060AnimFile\u0060 node wants pointed at it, and therefore what puts a hand-authored clip\n/// inside a compiled model where AnimGraph can reach it.\n///\n/// WHY THIS IS NOT SMD, WHICH IS THE OBVIOUS ANSWER AND THE WRONG ONE. A sequence SMD is the\n/// classic way to hand-write animation for a Source model, SmdWriter already emits the exact\n/// \u0060skeleton\u0060 / \u0060time N\u0060 / bone-row block a sequence needs, and extending it looks like an\n/// afternoon\u0027s work. ModelDoc does not read SMD at all \u2014 see DmxWriter\u0027s header for the loader\n/// string that says so in the compiler\u0027s own words. The mesh path learned that the expensive way;\n/// this file exists so the animation path does not learn it again.\n///\n/// COPIED, NOT GUESSED \u2014 and there is a command that produces the thing to copy. The engine ships\n/// \u0060bin/win64/fbx2dmx.exe\u0060, whose \u003Cb\u003E-a\u003C/b\u003E flag converts animation rather than geometry, and\n/// every element, attribute and spelling below was read off its output for a shipping clip:\n///\n///   fbx2dmx.exe -a -i addons/citizen/Assets/models/citizen/animations/face/Citizen@Eyes_Blink.fbx -o ref.dmx\n///\n/// That reference is the only evidence there is about this format, and regenerating it beats\n/// reasoning from this comment if anything here ever stops working. What it settled, none of which\n/// is inferable from the element names alone:\n///\n///   - \u0060animationList\u0060 hangs off the ROOT DmElement, beside \u0060skeleton\u0060 and \u0060model\u0060 \u2014 not inside\n///     the DmeModel, which is where it looks like it should go;\n///   - a channel targets the bone\u0027s DmeTransform, not its DmeJoint, and does it by id;\n///   - each bone needs TWO channels, suffixed \u0060_p\u0060 and \u0060_o\u0060, writing \u0060position\u0060 and \u0060orientation\u0060\n///     respectively. One channel per bone carrying both does not exist;\n///   - \u0060mode\u0060 is 3 on every channel fbx2dmx writes;\n///   - the log layer\u0027s \u0060curvetypes\u0060 array is present and EMPTY, which is what \u0022no per-key curve\n///     override\u0022 looks like. Omitting the array is a different statement.\n///\n/// WHAT IS DELIBERATELY LEFT OUT. The reference also carries a \u0060compressed\u0060 binary blob on each\n/// log layer \u2014 empty in every layer of it. An empty blob says nothing its absence does not, and\n/// KeyValues2\u0027s binary literal is a multi-line quoted form with no second example to check a guess\n/// against, so this writes no \u0060compressed\u0060 attribute rather than an invented one.\n/// \u0060dmxconvert.exe -i clip.dmx -o check.dmx\u0060 is the one-second check that this was the right call;\n/// DmxAnimTests runs the same parse with no engine involved.\n/// \u003C/summary\u003E\npublic static class DmxAnimWriter\n{\n\tpublic static void WriteFile( string path, Skeleton skeleton, AnimClip clip, string modelName = null )\n\t{\n\t\tFile.WriteAllText( path, Write( skeleton, clip, modelName ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The animation as a DMX document.\n\t///\n\t/// The skeleton written here has to be the SAME skeleton the mesh was exported with. ModelDoc\n\t/// matches a clip\u0027s channels to a model\u0027s bones by name, and a bone the clip poses that the\n\t/// model does not have is dropped silently \u2014 so a rig edited between the two exports gives you\n\t/// a clip that compiles, loads, and moves less of the model than it used to.\n\t/// \u003C/summary\u003E\n\tpublic static string Write( Skeleton skeleton, AnimClip clip, string modelName = null )\n\t{\n\t\tif ( skeleton is null )\n\t\t\tthrow new ArgumentNullException( nameof( skeleton ) );\n\n\t\tif ( clip is null )\n\t\t\tthrow new ArgumentNullException( nameof( clip ) );\n\n\t\tif ( clip.Validate( skeleton ) is { } problem )\n\t\t\tthrow new InvalidOperationException( $\u0022Animation clip does not fit its skeleton: {problem}\u0022 );\n\n\t\tmodelName ??= \u0022effigy_model\u0022;\n\n\t\tvar w = new DmxText();\n\n\t\t// Same id discipline as the mesh writer: counted, not random, so two exports of the same\n\t\t// clip are byte-identical and a diff shows what actually changed.\n\t\tvar idRoot = w.NextId();\n\t\tvar idModel = w.NextId();\n\t\tvar idModelTransform = w.NextId();\n\n\t\tvar boneDagIds = new string[skeleton.Count];\n\t\tvar boneTransformIds = new string[skeleton.Count];\n\t\tvar bindTransformIds = new string[skeleton.Count];\n\n\t\tfor ( var i = 0; i \u003C skeleton.Count; i\u002B\u002B )\n\t\t{\n\t\t\tboneDagIds[i] = w.NextId();\n\t\t\tboneTransformIds[i] = w.NextId();\n\t\t\tbindTransformIds[i] = w.NextId();\n\t\t}\n\n\t\tvar idAnimList = w.NextId();\n\t\tvar idClip = w.NextId();\n\t\tvar idTimeFrame = w.NextId();\n\n\t\tw.Raw( $\u0022\u003C!-- dmx encoding keyvalues2 1 format model {DmxWriter.ModelFormatVersion} --\u003E\u0022 );\n\t\tw.Raw( \u0022\u0022 );\n\n\t\tw.OpenElement( \u0022DmElement\u0022, idRoot, \u0022root\u0022 );\n\n\t\t// No mesh child: an animation DMX carries the rig and the motion, and the geometry lives in\n\t\t// the model file this clip gets compiled into.\n\t\tDmxWriter.WriteSkeletonModel( w, skeleton, modelName, idModel, idModelTransform,\n\t\t\tboneDagIds, boneTransformIds, bindTransformIds, null );\n\n\t\tw.Attribute( \u0022model\u0022, \u0022element\u0022, idModel );\n\n\t\tWriteAnimationList( w, skeleton, clip, idAnimList, idClip, idTimeFrame, boneTransformIds );\n\n\t\tw.CloseElement();\n\n\t\treturn w.ToString();\n\t}\n\n\t// --- pieces -------------------------------------------------------------------------------\n\n\tstatic void WriteAnimationList( DmxText w, Skeleton skeleton, AnimClip clip,\n\t\tstring idAnimList, string idClip, string idTimeFrame, string[] boneTransformIds )\n\t{\n\t\tw.OpenAttributeElement( \u0022animationList\u0022, \u0022DmeAnimationList\u0022, idAnimList, \u0022anim\u0022 );\n\t\tw.OpenArray( \u0022animations\u0022, \u0022element_array\u0022 );\n\n\t\tw.OpenArrayElement( \u0022DmeChannelsClip\u0022, idClip, clip.Name );\n\t\t{\n\t\t\tw.OpenAttributeElement( \u0022timeFrame\u0022, \u0022DmeTimeFrame\u0022, idTimeFrame, \u0022timeFrame\u0022 );\n\t\t\tw.Attribute( \u0022start\u0022, \u0022time\u0022, DmxText.Time( 0f ) );\n\t\t\tw.Attribute( \u0022duration\u0022, \u0022time\u0022, DmxText.Time( clip.Duration ) );\n\t\t\tw.Attribute( \u0022offset\u0022, \u0022time\u0022, DmxText.Time( 0f ) );\n\t\t\tw.Attribute( \u0022scale\u0022, \u0022float\u0022, \u00221\u0022 );\n\t\t\tw.CloseElement();\n\n\t\t\tw.OpenArray( \u0022channels\u0022, \u0022element_array\u0022 );\n\n\t\t\t// Two channels per bone, position then orientation, in skeleton order. The order is not\n\t\t\t// load-bearing \u2014 channels name their own target \u2014 but keeping it stable is what makes\n\t\t\t// two exports of the same clip diffable.\n\t\t\tfor ( var i = 0; i \u003C skeleton.Count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tWritePositionChannel( w, skeleton, clip, i, boneTransformIds[i] );\n\t\t\t\tWriteOrientationChannel( w, skeleton, clip, i, boneTransformIds[i] );\n\t\t\t}\n\n\t\t\tw.CloseArray();\n\t\t}\n\t\tw.CloseElement();\n\n\t\tw.CloseArray();\n\t\tw.CloseElement();\n\t}\n\n\tstatic void WritePositionChannel( DmxText w, Skeleton skeleton, AnimClip clip, int bone, string targetId )\n\t{\n\t\tw.OpenArrayElement( \u0022DmeChannel\u0022, w.NextId(), $\u0022{skeleton.Bones[bone].Name}_p\u0022 );\n\t\tWriteChannelTarget( w, targetId, \u0022position\u0022 );\n\n\t\tw.OpenAttributeElement( \u0022log\u0022, \u0022DmeVector3Log\u0022, w.NextId(), \u0022vector3 log\u0022 );\n\t\tw.OpenArray( \u0022layers\u0022, \u0022element_array\u0022 );\n\t\tw.OpenArrayElement( \u0022DmeVector3LogLayer\u0022, w.NextId(), \u0022vector3 log\u0022 );\n\n\t\tWriteTimes( w, clip );\n\n\t\tw.OpenArray( \u0022values\u0022, \u0022vector3_array\u0022 );\n\n\t\tfor ( var f = 0; f \u003C clip.FrameCount; f\u002B\u002B )\n\t\t\tw.ArrayValue( DmxText.Vector3( clip.Frames[f][bone].Origin ) );\n\n\t\tw.CloseArray();\n\t\tw.CloseElement();\n\t\tw.CloseArray();\n\n\t\tw.Attribute( \u0022usedefaultvalue\u0022, \u0022bool\u0022, \u00220\u0022 );\n\t\tw.Attribute( \u0022defaultvalue\u0022, \u0022vector3\u0022, \u00220 0 0\u0022 );\n\t\tw.CloseElement();\n\n\t\tw.CloseElement();\n\t}\n\n\tstatic void WriteOrientationChannel( DmxText w, Skeleton skeleton, AnimClip clip, int bone, string targetId )\n\t{\n\t\tw.OpenArrayElement( \u0022DmeChannel\u0022, w.NextId(), $\u0022{skeleton.Bones[bone].Name}_o\u0022 );\n\t\tWriteChannelTarget( w, targetId, \u0022orientation\u0022 );\n\n\t\tw.OpenAttributeElement( \u0022log\u0022, \u0022DmeQuaternionLog\u0022, w.NextId(), \u0022quaternion log\u0022 );\n\t\tw.OpenArray( \u0022layers\u0022, \u0022element_array\u0022 );\n\t\tw.OpenArrayElement( \u0022DmeQuaternionLogLayer\u0022, w.NextId(), \u0022quaternion log\u0022 );\n\n\t\tWriteTimes( w, clip );\n\n\t\tw.OpenArray( \u0022values\u0022, \u0022quaternion_array\u0022 );\n\n\t\tfor ( var f = 0; f \u003C clip.FrameCount; f\u002B\u002B )\n\t\t\tw.ArrayValue( DmxText.Quaternion( clip.Frames[f][bone] ) );\n\n\t\tw.CloseArray();\n\t\tw.CloseElement();\n\t\tw.CloseArray();\n\n\t\tw.Attribute( \u0022usedefaultvalue\u0022, \u0022bool\u0022, \u00220\u0022 );\n\t\tw.Attribute( \u0022defaultvalue\u0022, \u0022quaternion\u0022, \u00220 0 0 1\u0022 );\n\t\tw.CloseElement();\n\n\t\tw.CloseElement();\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The half of a channel that says where its values go.\n\t///\n\t/// \u0060fromElement\u0060 and \u0060fromAttribute\u0060 are empty because nothing drives this channel \u2014 it is a\n\t/// stored curve, not a connection between two live elements, which is the other thing a\n\t/// DmeChannel gets used for. \u0060mode\u0060 3 is what fbx2dmx writes on every channel of an exported\n\t/// clip; the enum it comes from is not in anything readable here, so this is copied rather than\n\t/// named.\n\t/// \u003C/summary\u003E\n\tstatic void WriteChannelTarget( DmxText w, string targetId, string attribute )\n\t{\n\t\tw.Attribute( \u0022fromElement\u0022, \u0022element\u0022, \u0022\u0022 );\n\t\tw.Attribute( \u0022fromAttribute\u0022, \u0022string\u0022, \u0022\u0022 );\n\t\tw.Attribute( \u0022fromIndex\u0022, \u0022int\u0022, \u00220\u0022 );\n\t\tw.Attribute( \u0022toElement\u0022, \u0022element\u0022, targetId );\n\t\tw.Attribute( \u0022toAttribute\u0022, \u0022string\u0022, attribute );\n\t\tw.Attribute( \u0022toIndex\u0022, \u0022int\u0022, \u00220\u0022 );\n\t\tw.Attribute( \u0022mode\u0022, \u0022int\u0022, \u00223\u0022 );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The sample times, plus the empty curvetypes array that goes with them.\n\t///\n\t/// Written once for both channel kinds because the two have to agree exactly: a position log\n\t/// and an orientation log with different time arrays is a bone whose translation and rotation\n\t/// drift apart, which reads as a rigging fault rather than an export one.\n\t/// \u003C/summary\u003E\n\tstatic void WriteTimes( DmxText w, AnimClip clip )\n\t{\n\t\tw.OpenArray( \u0022times\u0022, \u0022time_array\u0022 );\n\n\t\tfor ( var f = 0; f \u003C clip.FrameCount; f\u002B\u002B )\n\t\t\tw.ArrayValue( DmxText.Time( clip.TimeOf( f ) ) );\n\n\t\tw.CloseArray();\n\n\t\t// Present and empty \u2014 see the header. This is \u0022no per-key curve override\u0022, not \u0022no curves\u0022.\n\t\tw.OpenArray( \u0022curvetypes\u0022, \u0022int_array\u0022 );\n\t\tw.CloseArray();\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Brush.cs","FileName":"Brush.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\npublic enum BrushKind\n{\n\tSmooth,\n\tDraw,\n\tInflate,\n\tGrab,\n\tFlatten,\n\tPinch\n}\n\npublic enum BrushFalloff\n{\n\tSmooth,\n\tLinear,\n\tSharp,\n\tConstant\n}\n\n/// \u003Csummary\u003EOne sample on a stroke. The editor produces these; the kernel never learns what a mouse is.\u003C/summary\u003E\npublic readonly struct BrushSample\n{\n\tpublic readonly Vec3 Position;\n\tpublic readonly Vec3 Normal;\n\tpublic readonly Vec3 Direction;\n\tpublic readonly float Radius;\n\tpublic readonly float Strength;\n\n\tpublic BrushSample( Vec3 position, Vec3 normal, float radius, float strength, Vec3 direction = default )\n\t{\n\t\tPosition = position;\n\t\tNormal = normal;\n\t\tDirection = direction;\n\t\tRadius = radius;\n\t\tStrength = strength;\n\t}\n}\n\n/// \u003Csummary\u003EA list of samples plus the brush that consumes them.\u003C/summary\u003E\npublic sealed class BrushStroke\n{\n\tpublic BrushKind Kind;\n\tpublic BrushFalloff Falloff = BrushFalloff.Smooth;\n\tpublic bool MirrorX;\n\tpublic readonly List\u003CBrushSample\u003E Samples = new();\n}\n\n/// \u003Csummary\u003E\n/// Per-stroke undo: the original position of every vertex the stroke actually moved.\n/// A naive undo snapshots the whole mesh; this stores only the working set.\n/// \u003C/summary\u003E\npublic sealed class BrushUndo\n{\n\treadonly Dictionary\u003Cint, Vec3\u003E _previous = new();\n\n\tpublic int Count =\u003E _previous.Count;\n\n\t/// \u003Csummary\u003EEvery vertex this stroke moved, with the position it had BEFORE.\u003C/summary\u003E\n\tpublic IReadOnlyDictionary\u003Cint, Vec3\u003E Previous =\u003E _previous;\n\n\tinternal void Remember( int vertex, Vec3 position ) =\u003E _previous.TryAdd( vertex, position );\n\n\t/// \u003Csummary\u003E\n\t/// Fold a later undo into this one, so several brush applications read as a single stroke.\n\t///\n\t/// EARLIEST WINS, which is what TryAdd gives: a vertex moved by three dabs in one stroke has to\n\t/// go back to where it was before the FIRST of them, not before the last. Absorbing in the other\n\t/// order would leave the model two thirds sculpted after an undo, which looks like a brush bug\n\t/// rather than an undo one.\n\t/// \u003C/summary\u003E\n\tpublic void Absorb( BrushUndo later )\n\t{\n\t\tif ( later is null )\n\t\t\tthrow new ArgumentNullException( nameof( later ) );\n\n\t\tforeach ( var (vertex, position) in later._previous )\n\t\t\t_previous.TryAdd( vertex, position );\n\t}\n\n\tpublic void Restore( PolyMesh mesh )\n\t{\n\t\tif ( mesh is null )\n\t\t\tthrow new ArgumentNullException( nameof( mesh ) );\n\n\t\tforeach ( var (vertex, position) in _previous )\n\t\t\tmesh.Positions[vertex] = position;\n\t}\n}\n\n/// \u003Csummary\u003E\n/// Brushes as pure functions over a mesh and a stroke. Spatial queries go through\n/// \u003Csee cref=\u0022MeshBVH\u0022/\u003E; the kernel does not know about a cursor.\n/// \u003C/summary\u003E\npublic static class Brush\n{\n\tpublic static BrushUndo Apply( PolyMesh mesh, BrushStroke stroke, SculptFrames frames, float[] mask = null, MeshBVH bvh = null )\n\t{\n\t\tif ( mesh is null )\n\t\t\tthrow new ArgumentNullException( nameof( mesh ) );\n\n\t\tif ( stroke is null )\n\t\t\tthrow new ArgumentNullException( nameof( stroke ) );\n\n\t\tif ( frames is null )\n\t\t\tthrow new ArgumentNullException( nameof( frames ) );\n\n\t\tif ( frames.Count != mesh.VertexCount )\n\t\t\tthrow new ArgumentException( $\u0022frames ({frames.Count}) and mesh ({mesh.VertexCount}) disagree\u0022 );\n\n\t\tif ( mask is not null \u0026\u0026 mask.Length != mesh.VertexCount )\n\t\t\tthrow new ArgumentException( $\u0022mask ({mask.Length}) and mesh ({mesh.VertexCount}) disagree\u0022 );\n\n\t\tbvh ??= MeshBVH.Build( mesh );\n\n\t\tvar neighbors = mesh.BuildVertexEdges();\n\t\tvar found = new List\u003Cint\u003E();\n\t\tvar undo = new BrushUndo();\n\n\t\tforeach ( var sample in stroke.Samples )\n\t\t{\n\t\t\tApplySample( mesh, stroke, frames, mask, bvh, neighbors, found, undo, sample );\n\n\t\t\tif ( !stroke.MirrorX )\n\t\t\t\tcontinue;\n\n\t\t\tApplySample( mesh, stroke, frames, mask, bvh, neighbors, found, undo, MirrorX( sample ) );\n\t\t}\n\n\t\treturn undo;\n\t}\n\n\tstatic void ApplySample(\n\t\tPolyMesh mesh, BrushStroke stroke, SculptFrames frames, float[] mask,\n\t\tMeshBVH bvh, List\u003CEdgeKey\u003E[] neighbors, List\u003Cint\u003E found, BrushUndo undo, BrushSample sample )\n\t{\n\t\tif ( sample.Radius \u003C= 0f )\n\t\t\treturn;\n\n\t\tbvh.VerticesInRadius( mesh, sample.Position, sample.Radius, found );\n\n\t\tvar n = sample.Normal.LengthSquared \u003E= 0.5f ? sample.Normal.Normal : new Vec3( 0, 0, 1 );\n\t\tvar planePoint = Vec3.Zero;\n\t\tvar planeCount = 0;\n\n\t\tif ( stroke.Kind == BrushKind.Flatten )\n\t\t{\n\t\t\tforeach ( var vi in found )\n\t\t\t{\n\t\t\t\tplanePoint \u002B= mesh.Positions[vi];\n\t\t\t\tplaneCount\u002B\u002B;\n\t\t\t}\n\n\t\t\tif ( planeCount \u003E 0 )\n\t\t\t\tplanePoint /= planeCount;\n\t\t\telse\n\t\t\t\tplanePoint = sample.Position;\n\t\t}\n\n\t\tforeach ( var vi in found )\n\t\t{\n\t\t\tvar pos = mesh.Positions[vi];\n\t\t\tvar dist = (pos - sample.Position).Length;\n\t\t\tvar t = dist / sample.Radius;\n\t\t\tvar weight = Falloff( t, stroke.Falloff ) * sample.Strength * (mask is null ? 1f : mask[vi]);\n\n\t\t\tif ( MathF.Abs( weight ) \u003C 1e-8f )\n\t\t\t\tcontinue;\n\n\t\t\tVec3 next;\n\n\t\t\tswitch ( stroke.Kind )\n\t\t\t{\n\t\t\t\tcase BrushKind.Smooth:\n\t\t\t\t\tnext = Vec3.Lerp( pos, NeighbourAverage( mesh, neighbors[vi], vi ), Math.Clamp( weight, 0f, 1f ) );\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase BrushKind.Draw:\n\t\t\t\t\tnext = pos \u002B n * weight;\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase BrushKind.Inflate:\n\t\t\t\t\tnext = pos \u002B frames.At[vi].Normal * weight;\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase BrushKind.Grab:\n\t\t\t\t\tnext = pos \u002B sample.Direction * weight;\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase BrushKind.Flatten:\n\t\t\t\t\tvar d = Vec3.Dot( pos - planePoint, n );\n\t\t\t\t\tnext = pos - n * (d * Math.Clamp( weight, 0f, 1f ));\n\t\t\t\t\tbreak;\n\n\t\t\t\tcase BrushKind.Pinch:\n\t\t\t\t\tvar along = Vec3.Dot( pos - sample.Position, n );\n\t\t\t\t\tvar closest = sample.Position \u002B n * along;\n\t\t\t\t\tnext = Vec3.Lerp( pos, closest, Math.Clamp( weight, 0f, 1f ) );\n\t\t\t\t\tbreak;\n\n\t\t\t\tdefault:\n\t\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( next.AlmostEquals( pos, 1e-8f ) )\n\t\t\t\tcontinue;\n\n\t\t\tundo.Remember( vi, pos );\n\t\t\tmesh.Positions[vi] = next;\n\t\t}\n\n\t\tbvh.Refit( mesh );\n\t}\n\n\tpublic static float Falloff( float t, BrushFalloff kind )\n\t{\n\t\tt = Math.Clamp( t, 0f, 1f );\n\n\t\treturn kind switch\n\t\t{\n\t\t\tBrushFalloff.Constant =\u003E 1f,\n\t\t\tBrushFalloff.Linear =\u003E 1f - t,\n\t\t\tBrushFalloff.Sharp =\u003E (1f - t) * (1f - t),\n\t\t\t_ =\u003E 1f - t * t * (3f - 2f * t)\n\t\t};\n\t}\n\n\tstatic Vec3 NeighbourAverage( PolyMesh mesh, List\u003CEdgeKey\u003E edges, int vi )\n\t{\n\t\tif ( edges.Count == 0 )\n\t\t\treturn mesh.Positions[vi];\n\n\t\tvar sum = Vec3.Zero;\n\n\t\tforeach ( var key in edges )\n\t\t\tsum \u002B= mesh.Positions[key.A == vi ? key.B : key.A];\n\n\t\treturn sum / edges.Count;\n\t}\n\n\tstatic BrushSample MirrorX( BrushSample s ) =\u003E\n\t\tnew(\n\t\t\tnew Vec3( -s.Position.x, s.Position.y, s.Position.z ),\n\t\t\tnew Vec3( -s.Normal.x, s.Normal.y, s.Normal.z ),\n\t\t\ts.Radius,\n\t\t\ts.Strength,\n\t\t\tnew Vec3( -s.Direction.x, s.Direction.y, s.Direction.z ) );\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Expression.cs","FileName":"Expression.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Globalization;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// The evaluator behind Onshape\u0027s numeric fields.\n///\n/// Onshape\u0027s numeric fields \u0022accept integers, decimals, parameter expressions, and trigonometric\n/// functions\u0022, with the documented operator set ^ * / \u002B - and the documented function set ceil,\n/// floor, round, exp, sqrt, abs, max, min and log. Typing \u00601/8\u0060 and getting 0.125 is not a\n/// nicety - a slider cannot express a fraction, a tapped hole size, or \u0022half of the last one\u0022,\n/// and those are most of what a dimension actually is.\n///\n/// Deliberately hand-written rather than pulled from a package: the kernel\u0027s whole point is\n/// that it has no dependencies. It lives in the kernel rather than the editor because it has no\n/// engine surface whatsoever - which also means it can be compiled and exercised directly, and\n/// ExpressionTests does exactly that.\n///\n/// UNITS. Effigy\u0027s lengths are dimensionless (see EffigyViewport.FrameCamera - a default Box is\n/// one unit on a side), so length fields take bare numbers and reject unit suffixes rather than\n/// inventing a millimetre that nothing downstream honours. Angle fields are real, because\n/// FloatParam.Unit already carries \u0022deg\u0022, so those accept \u0060deg\u0060, \u0060rad\u0060 and \u0060\u00B0\u0060.\n///\n/// TRIG IS IN DEGREES. sin(30) is 0.5. Onshape\u0027s trig takes a unit-carrying angle and cannot be\n/// ambiguous; with no unit system here, degrees is what someone typing into a CAD field means.\n/// \u003C/summary\u003E\npublic static class Expression\n{\n\t/// \u003Csummary\u003E\n\t/// Evaluate an expression. False when it is not a well-formed expression at all, which is the\n\t/// signal for the field to hold its last good value rather than clobbering the parameter with\n\t/// a NaN halfway through someone typing \u00221/\u0022.\n\t/// \u003C/summary\u003E\n\t/// \u003Cparam name=\u0022text\u0022\u003EWhat the user typed.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022unit\u0022\u003EThe parameter\u0027s unit - \u0022deg\u0022 for an angle, null for a bare number.\u003C/param\u003E\n\t/// \u003Cparam name=\u0022value\u0022\u003EThe result, in the parameter\u0027s own unit.\u003C/param\u003E\n\tpublic static bool TryEvaluate( string text, string unit, out float value )\n\t{\n\t\tvalue = 0f;\n\n\t\tif ( string.IsNullOrWhiteSpace( text ) )\n\t\t\treturn false;\n\n\t\ttry\n\t\t{\n\t\t\tvar parser = new Parser( text, unit );\n\t\t\tvar result = parser.ParseExpression();\n\n\t\t\tparser.SkipSpace();\n\n\t\t\t// Trailing junk means the whole string was not an expression. \u00222 3\u0022 is a typo, not a 2.\n\t\t\tif ( !parser.AtEnd )\n\t\t\t\treturn false;\n\n\t\t\tif ( float.IsNaN( result ) || float.IsInfinity( result ) )\n\t\t\t\treturn false;\n\n\t\t\tvalue = result;\n\t\t\treturn true;\n\t\t}\n\t\tcatch ( FormatException )\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EHow a committed value is written back into the field. Onshape shows the evaluated\n\t/// result once a numeric field is accepted; trailing zeros on an integral value read as noise\n\t/// in a dimension, so 4 stays 4 rather than becoming 4.000.\u003C/summary\u003E\n\tpublic static string Format( float value )\n\t{\n\t\tif ( MathF.Abs( value - MathF.Round( value ) ) \u003C 1e-6f )\n\t\t\treturn ((int)MathF.Round( value )).ToString( CultureInfo.InvariantCulture );\n\n\t\treturn value.ToString( \u00220.####\u0022, CultureInfo.InvariantCulture );\n\t}\n\n\t// --- parser -------------------------------------------------------------------------------\n\n\t/// \u003Csummary\u003E\n\t/// Recursive descent over\n\t///\n\t///   expression := term ((\u0027\u002B\u0027|\u0027-\u0027) term)*\n\t///   term       := unary ((\u0027*\u0027|\u0027/\u0027) unary)*\n\t///   unary      := (\u0027-\u0027|\u0027\u002B\u0027) unary | power\n\t///   power      := atom (\u0027^\u0027 unary)?\n\t///   atom       := number unit? | name \u0027(\u0027 args \u0027)\u0027 | name | \u0027(\u0027 expression \u0027)\u0027\n\t///\n\t/// The unary/power split is what makes -2^2 evaluate to -4 and 2^-1 to 0.5, which is the\n\t/// convention every calculator and every CAD field uses. Getting it wrong is silent: the\n\t/// expression still evaluates, to the wrong number.\n\t/// \u003C/summary\u003E\n\tsealed class Parser\n\t{\n\t\treadonly string _text;\n\t\treadonly string _unit;\n\t\tint _pos;\n\n\t\tpublic Parser( string text, string unit )\n\t\t{\n\t\t\t_text = text;\n\t\t\t_unit = unit;\n\t\t}\n\n\t\tpublic bool AtEnd =\u003E _pos \u003E= _text.Length;\n\n\t\tpublic void SkipSpace()\n\t\t{\n\t\t\twhile ( _pos \u003C _text.Length \u0026\u0026 char.IsWhiteSpace( _text[_pos] ) )\n\t\t\t\t_pos\u002B\u002B;\n\t\t}\n\n\t\tchar Peek()\n\t\t{\n\t\t\tSkipSpace();\n\t\t\treturn _pos \u003C _text.Length ? _text[_pos] : \u0027\\0\u0027;\n\t\t}\n\n\t\tbool Take( char c )\n\t\t{\n\t\t\tif ( Peek() != c )\n\t\t\t\treturn false;\n\n\t\t\t_pos\u002B\u002B;\n\t\t\treturn true;\n\t\t}\n\n\t\tpublic float ParseExpression()\n\t\t{\n\t\t\tvar left = ParseTerm();\n\n\t\t\twhile ( true )\n\t\t\t{\n\t\t\t\tif ( Take( \u0027\u002B\u0027 ) ) left \u002B= ParseTerm();\n\t\t\t\telse if ( Take( \u0027-\u0027 ) ) left -= ParseTerm();\n\t\t\t\telse return left;\n\t\t\t}\n\t\t}\n\n\t\tfloat ParseTerm()\n\t\t{\n\t\t\tvar left = ParseUnary();\n\n\t\t\twhile ( true )\n\t\t\t{\n\t\t\t\tif ( Take( \u0027*\u0027 ) )\n\t\t\t\t{\n\t\t\t\t\tleft *= ParseUnary();\n\t\t\t\t}\n\t\t\t\telse if ( Take( \u0027/\u0027 ) )\n\t\t\t\t{\n\t\t\t\t\tvar divisor = ParseUnary();\n\n\t\t\t\t\t// Not an exception: a field reading \u00221/0\u0022 mid-type is a half-finished thought,\n\t\t\t\t\t// and NaN out is how TryEvaluate knows to keep the previous value.\n\t\t\t\t\tif ( divisor == 0f )\n\t\t\t\t\t\treturn float.NaN;\n\n\t\t\t\t\tleft /= divisor;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\treturn left;\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\tfloat ParseUnary()\n\t\t{\n\t\t\tif ( Take( \u0027-\u0027 ) )\n\t\t\t\treturn -ParseUnary();\n\n\t\t\tif ( Take( \u0027\u002B\u0027 ) )\n\t\t\t\treturn ParseUnary();\n\n\t\t\treturn ParsePower();\n\t\t}\n\n\t\tfloat ParsePower()\n\t\t{\n\t\t\tvar b = ParseAtom();\n\n\t\t\t// Right-associative, and the exponent goes through unary so 2^-1 parses.\n\t\t\tif ( Take( \u0027^\u0027 ) )\n\t\t\t\treturn MathF.Pow( b, ParseUnary() );\n\n\t\t\treturn b;\n\t\t}\n\n\t\tfloat ParseAtom()\n\t\t{\n\t\t\tSkipSpace();\n\n\t\t\tif ( AtEnd )\n\t\t\t\tthrow new FormatException( \u0022unexpected end of expression\u0022 );\n\n\t\t\tif ( Take( \u0027(\u0027 ) )\n\t\t\t{\n\t\t\t\tvar inner = ParseExpression();\n\n\t\t\t\tif ( !Take( \u0027)\u0027 ) )\n\t\t\t\t\tthrow new FormatException( \u0022unclosed bracket\u0022 );\n\n\t\t\t\treturn inner;\n\t\t\t}\n\n\t\t\tvar c = _text[_pos];\n\n\t\t\tif ( char.IsDigit( c ) || c == \u0027.\u0027 )\n\t\t\t\treturn ParseNumber();\n\n\t\t\tif ( char.IsLetter( c ) || c == \u0027_\u0027 )\n\t\t\t\treturn ParseName();\n\n\t\t\tthrow new FormatException( $\u0022unexpected \u0027{c}\u0027\u0022 );\n\t\t}\n\n\t\tfloat ParseNumber()\n\t\t{\n\t\t\tvar start = _pos;\n\n\t\t\twhile ( _pos \u003C _text.Length \u0026\u0026 (char.IsDigit( _text[_pos] ) || _text[_pos] == \u0027.\u0027) )\n\t\t\t\t_pos\u002B\u002B;\n\n\t\t\t// Exponent form, but only when it really is one - \u00602e\u0060 is a 2 times the constant e,\n\t\t\t// and \u00602early\u0060 is a typo. Both have to stay out of the number.\n\t\t\tif ( _pos \u003C _text.Length \u0026\u0026 (_text[_pos] == \u0027e\u0027 || _text[_pos] == \u0027E\u0027) )\n\t\t\t{\n\t\t\t\tvar save = _pos;\n\t\t\t\tvar probe = _pos \u002B 1;\n\n\t\t\t\tif ( probe \u003C _text.Length \u0026\u0026 (_text[probe] == \u0027\u002B\u0027 || _text[probe] == \u0027-\u0027) )\n\t\t\t\t\tprobe\u002B\u002B;\n\n\t\t\t\tif ( probe \u003C _text.Length \u0026\u0026 char.IsDigit( _text[probe] ) )\n\t\t\t\t{\n\t\t\t\t\t_pos = probe;\n\n\t\t\t\t\twhile ( _pos \u003C _text.Length \u0026\u0026 char.IsDigit( _text[_pos] ) )\n\t\t\t\t\t\t_pos\u002B\u002B;\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\t_pos = save;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar span = _text.Substring( start, _pos - start );\n\n\t\t\tif ( !float.TryParse( span, NumberStyles.Float, CultureInfo.InvariantCulture, out var number ) )\n\t\t\t\tthrow new FormatException( $\u0022\u0027{span}\u0027 is not a number\u0022 );\n\n\t\t\treturn ApplyUnitSuffix( number );\n\t\t}\n\n\t\t/// \u003Csummary\u003E\n\t\t/// A unit written straight after a number, converted into the field\u0027s own unit.\n\t\t///\n\t\t/// Length fields reject suffixes outright rather than silently ignoring them: the kernel is\n\t\t/// dimensionless, so accepting \u00605mm\u0060 and storing 5 would be a worse lie than refusing it.\n\t\t/// \u003C/summary\u003E\n\t\tfloat ApplyUnitSuffix( float number )\n\t\t{\n\t\t\tif ( _pos \u003C _text.Length \u0026\u0026 _text[_pos] == \u0027\u00B0\u0027 )\n\t\t\t{\n\t\t\t\t_pos\u002B\u002B;\n\n\t\t\t\tif ( _unit != \u0022deg\u0022 )\n\t\t\t\t\tthrow new FormatException( \u0022this field has no angle unit\u0022 );\n\n\t\t\t\treturn number;\n\t\t\t}\n\n\t\t\tvar start = _pos;\n\n\t\t\twhile ( _pos \u003C _text.Length \u0026\u0026 char.IsLetter( _text[_pos] ) )\n\t\t\t\t_pos\u002B\u002B;\n\n\t\t\tif ( _pos == start )\n\t\t\t\treturn number;\n\n\t\t\tvar suffix = _text.Substring( start, _pos - start ).ToLowerInvariant();\n\n\t\t\t// Not a unit - put the letters back and let the trailing-junk check in TryEvaluate\n\t\t\t// reject the whole string. There is no implicit multiplication, so \u00602pi\u0060 is a typo\n\t\t\t// rather than 2*pi; failing loudly beats guessing which one was meant.\n\t\t\tif ( !IsUnit( suffix ) )\n\t\t\t{\n\t\t\t\t_pos = start;\n\t\t\t\treturn number;\n\t\t\t}\n\n\t\t\tif ( _unit != \u0022deg\u0022 )\n\t\t\t\tthrow new FormatException( $\u0022\u0027{suffix}\u0027 is an angle unit and this field is a plain number\u0022 );\n\n\t\t\treturn suffix == \u0022rad\u0022 ? number * (180f / MathF.PI) : number;\n\t\t}\n\n\t\tstatic bool IsUnit( string s ) =\u003E s is \u0022deg\u0022 or \u0022degree\u0022 or \u0022degrees\u0022 or \u0022rad\u0022 or \u0022radian\u0022 or \u0022radians\u0022;\n\n\t\tfloat ParseName()\n\t\t{\n\t\t\tvar start = _pos;\n\n\t\t\twhile ( _pos \u003C _text.Length \u0026\u0026 (char.IsLetterOrDigit( _text[_pos] ) || _text[_pos] == \u0027_\u0027) )\n\t\t\t\t_pos\u002B\u002B;\n\n\t\t\tvar name = _text.Substring( start, _pos - start ).ToLowerInvariant();\n\n\t\t\tif ( Peek() == \u0027(\u0027 )\n\t\t\t{\n\t\t\t\t_pos\u002B\u002B; // the \u0027(\u0027 that Peek found\n\t\t\t\tvar args = new List\u003Cfloat\u003E();\n\n\t\t\t\tif ( Peek() != \u0027)\u0027 )\n\t\t\t\t{\n\t\t\t\t\targs.Add( ParseExpression() );\n\n\t\t\t\t\twhile ( Take( \u0027,\u0027 ) )\n\t\t\t\t\t\targs.Add( ParseExpression() );\n\t\t\t\t}\n\n\t\t\t\tif ( !Take( \u0027)\u0027 ) )\n\t\t\t\t\tthrow new FormatException( $\u0022unclosed bracket after {name}\u0022 );\n\n\t\t\t\treturn Call( name, args );\n\t\t\t}\n\n\t\t\treturn name switch\n\t\t\t{\n\t\t\t\t\u0022pi\u0022 =\u003E MathF.PI,\n\t\t\t\t\u0022tau\u0022 =\u003E MathF.Tau,\n\t\t\t\t\u0022e\u0022 =\u003E MathF.E,\n\t\t\t\t_ =\u003E throw new FormatException( $\u0022unknown name \u0027{name}\u0027\u0022 ),\n\t\t\t};\n\t\t}\n\n\t\tstatic float Call( string name, List\u003Cfloat\u003E a )\n\t\t{\n\t\t\tfloat One() =\u003E a.Count == 1 ? a[0] : throw new FormatException( $\u0022{name} takes one argument\u0022 );\n\t\t\tfloat Two( int i ) =\u003E a.Count == 2 ? a[i] : throw new FormatException( $\u0022{name} takes two arguments\u0022 );\n\n\t\t\tconst float ToRad = MathF.PI / 180f;\n\t\t\tconst float ToDeg = 180f / MathF.PI;\n\n\t\t\treturn name switch\n\t\t\t{\n\t\t\t\t\u0022sqrt\u0022 =\u003E MathF.Sqrt( One() ),\n\t\t\t\t\u0022abs\u0022 =\u003E MathF.Abs( One() ),\n\t\t\t\t\u0022floor\u0022 =\u003E MathF.Floor( One() ),\n\t\t\t\t\u0022ceil\u0022 =\u003E MathF.Ceiling( One() ),\n\t\t\t\t\u0022round\u0022 =\u003E MathF.Round( One() ),\n\t\t\t\t\u0022sign\u0022 =\u003E MathF.Sign( One() ),\n\t\t\t\t\u0022exp\u0022 =\u003E MathF.Exp( One() ),\n\t\t\t\t\u0022log\u0022 =\u003E MathF.Log( One() ),\n\t\t\t\t\u0022log10\u0022 =\u003E MathF.Log10( One() ),\n\n\t\t\t\t// Degrees in, degrees out - see the class remarks.\n\t\t\t\t\u0022sin\u0022 =\u003E MathF.Sin( One() * ToRad ),\n\t\t\t\t\u0022cos\u0022 =\u003E MathF.Cos( One() * ToRad ),\n\t\t\t\t\u0022tan\u0022 =\u003E MathF.Tan( One() * ToRad ),\n\t\t\t\t\u0022asin\u0022 =\u003E MathF.Asin( One() ) * ToDeg,\n\t\t\t\t\u0022acos\u0022 =\u003E MathF.Acos( One() ) * ToDeg,\n\t\t\t\t\u0022atan\u0022 =\u003E MathF.Atan( One() ) * ToDeg,\n\n\t\t\t\t\u0022min\u0022 =\u003E MathF.Min( Two( 0 ), Two( 1 ) ),\n\t\t\t\t\u0022max\u0022 =\u003E MathF.Max( Two( 0 ), Two( 1 ) ),\n\t\t\t\t\u0022pow\u0022 =\u003E MathF.Pow( Two( 0 ), Two( 1 ) ),\n\t\t\t\t\u0022atan2\u0022 =\u003E MathF.Atan2( Two( 0 ), Two( 1 ) ) * ToDeg,\n\n\t\t\t\t_ =\u003E throw new FormatException( $\u0022unknown function \u0027{name}\u0027\u0022 ),\n\t\t\t};\n\t\t}\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/MeshHoleRepairSpan.cs","FileName":"MeshHoleRepairSpan.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// Closing a mouth that lies across TWO faces rather than inside one.\n///\n/// WHAT WAS DECLINED AND WHY. \u0060MeshHoleRepair.FindContainingFace\u0060 wants one coplanar face that\n/// contains the whole loop, uniquely \u2014 and it is right to, because a guess there seals a surface the\n/// wrong way and the result is closed, manifold and wrong. But a cut that lands where two coplanar\n/// faces meet has a mouth in both of them and no single containing face exists, so the repair walked\n/// away and the opening stayed open. A cut meeting an edge so the mouth spans two faces\n/// needs the loop split where it crosses that edge.\n///\n/// THE FIX IS A DETOUR, NOT A PATCH. Each face keeps its own boundary; where that boundary runs\n/// along the shared edge, it detours around the half of the mouth on its side. A left quad whose\n/// edge runs (0,-2) to (0,2) with a mouth crossing at (0,-1) and (0,1) becomes:\n///\n///     ... (0,-2) -\u003E (0,-1) -\u003E [the arc through the left half of the mouth] -\u003E (0,1) -\u003E (0,2) ...\n///\n/// which is one notched face, not a face plus a patch. Nothing is added, nothing is triangulated,\n/// and the two faces still meet along what is left of their shared edge.\n///\n/// WHAT IT STILL DECLINES, and deliberately:\n///\n/// - a loop crossing a face boundary somewhere that is not a vertex of the loop. The crossing point\n///   has to already exist as a vertex, because inventing one means splitting a face this repair was\n///   not asked to touch and cannot see the consequences of.\n/// - a loop entering and leaving one face more than once. That is a face with two notches or a\n///   notch and a hole, and telling those apart needs the containment test the single-face path\n///   already does properly.\n/// - anything non-planar. A mouth on a curved surface is a different problem and is still open.\n/// \u003C/summary\u003E\npublic static class MeshHoleRepairSpan\n{\n\tconst float PlaneTolerance = 1e-4f;\n\tconst float NormalTolerance = 0.999f;\n\n\t/// \u003Csummary\u003E\n\t/// Close every boundary loop that straddles exactly two coplanar faces. Returns how many.\n\t///\n\t/// Run AFTER the single-face repair, so it only ever sees what that one declined.\n\t/// \u003C/summary\u003E\n\tpublic static int CloseLoopsSpanningFaces( PolyMesh mesh )\n\t{\n\t\tif ( mesh is null || mesh.FaceCount == 0 )\n\t\t\treturn 0;\n\n\t\tvar closed = 0;\n\n\t\t// Re-derived each time round: closing one loop changes the faces, and a stale list would\n\t\t// splice the next loop into a face that no longer looks like that.\n\t\twhile ( true )\n\t\t{\n\t\t\tvar loops = BoundaryLoops( mesh );\n\t\t\tvar progressed = false;\n\n\t\t\tforeach ( var loop in loops )\n\t\t\t{\n\t\t\t\tif ( loop.Count \u003C 3 || !TryCloseAcross( mesh, loop ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tclosed\u002B\u002B;\n\t\t\t\tprogressed = true;\n\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif ( !progressed )\n\t\t\t\treturn closed;\n\t\t}\n\t}\n\n\tstatic bool TryCloseAcross( PolyMesh mesh, List\u003Cint\u003E loop )\n\t{\n\t\tvar normal = LoopNormal( mesh, loop );\n\n\t\tif ( normal.LengthSquared \u003C 1e-20f )\n\t\t\treturn false;\n\n\t\tnormal = normal.Normal;\n\n\t\tBasis( normal, out var u, out var v );\n\n\t\tvar plane = Vec3.Dot( mesh.Positions[loop[0]], normal );\n\t\tvar candidates = new List\u003Cint\u003E();\n\n\t\tfor ( var fi = 0; fi \u003C mesh.FaceCount; fi\u002B\u002B )\n\t\t{\n\t\t\tvar face = mesh.Faces[fi];\n\n\t\t\tif ( face.Count \u003C 3 )\n\t\t\t\tcontinue;\n\n\t\t\tvar faceNormal = mesh.FaceNormal( face );\n\n\t\t\tif ( faceNormal.LengthSquared \u003C 1e-20f )\n\t\t\t\tcontinue;\n\n\t\t\tif ( MathF.Abs( Vec3.Dot( faceNormal.Normal, normal ) ) \u003C NormalTolerance )\n\t\t\t\tcontinue;\n\n\t\t\tif ( MathF.Abs( Vec3.Dot( mesh.Positions[face.Indices[0]], normal ) - plane ) \u003E PlaneTolerance )\n\t\t\t\tcontinue;\n\n\t\t\t// A face already using a loop vertex as one of its own corners is the wall the loop came\n\t\t\t// from, not the surface it is a mouth in.\n\t\t\tif ( SharesAnyVertex( face, loop ) )\n\t\t\t\tcontinue;\n\n\t\t\tcandidates.Add( fi );\n\t\t}\n\n\t\t// Exactly two. One is the single-face case, which the other repair does properly; three or\n\t\t// more is a mouth crossing a corner, and which face owns which arc stops being obvious.\n\t\tif ( candidates.Count != 2 )\n\t\t\treturn false;\n\n\t\tvar first = candidates[0];\n\t\tvar second = candidates[1];\n\n\t\tif ( !SplitAcross( mesh, loop, first, second, u, v, out var arcs ) )\n\t\t\treturn false;\n\n\t\t// Both notches built before either is written. A half-applied repair leaves a mesh that is\n\t\t// worse than the one that came in, and this is exactly where that could happen.\n\t\tif ( !BuildNotched( mesh, first, arcs.First, u, v, out var firstFace ) )\n\t\t\treturn false;\n\n\t\tif ( !BuildNotched( mesh, second, arcs.Second, u, v, out var secondFace ) )\n\t\t\treturn false;\n\n\t\tmesh.Faces[first] = firstFace;\n\t\tmesh.Faces[second] = secondFace;\n\n\t\treturn true;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Cut the loop into the two arcs that belong to the two faces.\n\t///\n\t/// The joins are the loop vertices that sit on BOTH faces\u0027 boundaries \u2014 the points where the\n\t/// mouth crosses the shared edge. There have to be exactly two of them; one means the loop only\n\t/// touches the edge, and more means it weaves back and forth, which is the case this declines.\n\t/// \u003C/summary\u003E\n\tstatic bool SplitAcross( PolyMesh mesh, List\u003Cint\u003E loop, int a, int b, Vec3 u, Vec3 v,\n\t\tout (List\u003Cint\u003E First, List\u003Cint\u003E Second) arcs )\n\t{\n\t\tarcs = (null, null);\n\n\t\tvar polygonA = Polygon( mesh, mesh.Faces[a], u, v );\n\t\tvar polygonB = Polygon( mesh, mesh.Faces[b], u, v );\n\n\t\tvar joins = new List\u003Cint\u003E();\n\t\tvar side = new int[loop.Count];\n\n\t\tfor ( var i = 0; i \u003C loop.Count; i\u002B\u002B )\n\t\t{\n\t\t\tvar p = Flatten( mesh.Positions[loop[i]], u, v );\n\t\t\tvar onA = OnBoundary( polygonA, p );\n\t\t\tvar onB = OnBoundary( polygonB, p );\n\n\t\t\tif ( onA \u0026\u0026 onB )\n\t\t\t{\n\t\t\t\tjoins.Add( i );\n\t\t\t\tside[i] = 0;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar inA = PointInPolygon( polygonA, p );\n\t\t\tvar inB = PointInPolygon( polygonB, p );\n\n\t\t\t// A vertex in neither face means the loop leaves the two faces entirely, and a vertex in\n\t\t\t// both that is not on a shared boundary means they overlap - neither is this case.\n\t\t\tif ( inA == inB )\n\t\t\t\treturn false;\n\n\t\t\tside[i] = inA ? 1 : 2;\n\t\t}\n\n\t\tif ( joins.Count != 2 )\n\t\t\treturn false;\n\n\t\tvar firstArc = Arc( loop, side, joins[0], joins[1] );\n\t\tvar secondArc = Arc( loop, side, joins[1], joins[0] );\n\n\t\tif ( firstArc is null || secondArc is null )\n\t\t\treturn false;\n\n\t\t// One arc per face, sorted by which side its interior vertices sat on.\n\t\tvar firstSide = InteriorSide( side, loop.Count, joins[0], joins[1] );\n\t\tvar secondSide = InteriorSide( side, loop.Count, joins[1], joins[0] );\n\n\t\tif ( firstSide == 0 || secondSide == 0 || firstSide == secondSide )\n\t\t\treturn false;\n\n\t\tarcs = firstSide == 1 ? (firstArc, secondArc) : (secondArc, firstArc);\n\t\treturn true;\n\t}\n\n\t/// \u003Csummary\u003EThe run of loop vertices from one join to the next, inclusive of both.\u003C/summary\u003E\n\tstatic List\u003Cint\u003E Arc( List\u003Cint\u003E loop, int[] side, int from, int to )\n\t{\n\t\tvar arc = new List\u003Cint\u003E();\n\t\tvar i = from;\n\n\t\tfor ( var guard = 0; guard \u003C= loop.Count; guard\u002B\u002B )\n\t\t{\n\t\t\tarc.Add( loop[i] );\n\n\t\t\tif ( i == to \u0026\u0026 arc.Count \u003E 1 )\n\t\t\t\treturn arc;\n\n\t\t\ti = (i \u002B 1) % loop.Count;\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t/// \u003Csummary\u003EWhich face the vertices strictly between two joins belong to, or 0 if they disagree.\u003C/summary\u003E\n\tstatic int InteriorSide( int[] side, int count, int from, int to )\n\t{\n\t\tvar found = 0;\n\n\t\tfor ( var i = (from \u002B 1) % count; i != to; i = (i \u002B 1) % count )\n\t\t{\n\t\t\tif ( side[i] == 0 )\n\t\t\t\tcontinue;\n\n\t\t\tif ( found == 0 )\n\t\t\t\tfound = side[i];\n\t\t\telse if ( found != side[i] )\n\t\t\t\treturn 0;\n\t\t}\n\n\t\treturn found;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The face, re-walked with the arc spliced in where its boundary passes the mouth.\n\t///\n\t/// The arc\u0027s two ends lie ON one of the face\u0027s edges, so the new boundary follows the face until\n\t/// it reaches that edge, detours along the arc, and picks the face up again. The arc is taken in\n\t/// whichever direction leaves the face wound the way it already was \u2014 a notch that reverses the\n\t/// winding is a face pointing into the solid, which renders black and looks fine in wireframe.\n\t/// \u003C/summary\u003E\n\tstatic bool BuildNotched( PolyMesh mesh, int faceIndex, List\u003Cint\u003E arc, Vec3 u, Vec3 v, out Face result )\n\t{\n\t\tresult = null;\n\n\t\tvar face = mesh.Faces[faceIndex];\n\t\tvar start = arc[0];\n\t\tvar end = arc[^1];\n\n\t\tvar startEdge = EdgeCarrying( mesh, face, start, u, v );\n\t\tvar endEdge = EdgeCarrying( mesh, face, end, u, v );\n\n\t\tif ( startEdge \u003C 0 || endEdge \u003C 0 || startEdge != endEdge )\n\t\t\treturn false;\n\n\t\tvar a = Flatten( mesh.Positions[face.Indices[startEdge]], u, v );\n\t\tvar from = Flatten( mesh.Positions[start], u, v );\n\t\tvar to = Flatten( mesh.Positions[end], u, v );\n\n\t\t// Which end of the arc the face\u0027s own winding reaches first along that edge.\n\t\tvar startFirst = (from - a).LengthSquared \u003C= (to - a).LengthSquared;\n\t\tvar walk = new List\u003Cint\u003E( face.Count \u002B arc.Count );\n\n\t\tfor ( var i = 0; i \u003C face.Count; i\u002B\u002B )\n\t\t{\n\t\t\twalk.Add( face.Indices[i] );\n\n\t\t\tif ( i != startEdge )\n\t\t\t\tcontinue;\n\n\t\t\tif ( startFirst )\n\t\t\t{\n\t\t\t\twalk.AddRange( arc );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tfor ( var j = arc.Count - 1; j \u003E= 0; j-- )\n\t\t\t\t\twalk.Add( arc[j] );\n\t\t\t}\n\t\t}\n\n\t\t// A repeated index means the arc met the face at a corner it already owns, and the polygon\n\t\t// would pinch there. Refuse rather than emit a face that touches itself.\n\t\tvar seen = new HashSet\u003Cint\u003E();\n\n\t\tforeach ( var index in walk )\n\t\t{\n\t\t\tif ( !seen.Add( index ) )\n\t\t\t\treturn false;\n\t\t}\n\n\t\tresult = new Face( walk.ToArray(), NewUVs( mesh, face, walk, u, v ), face.Material );\n\t\treturn true;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// UVs for the rebuilt face, carried across rather than reset.\n\t///\n\t/// The face\u0027s own corners keep the UVs they had. The arc\u0027s vertices are new to this face and get\n\t/// theirs by the same planar mapping the original corners already follow, so a notch does not\n\t/// smear the texture across the surface it was cut into.\n\t/// \u003C/summary\u003E\n\tstatic Vec2[] NewUVs( PolyMesh mesh, Face face, List\u003Cint\u003E walk, Vec3 u, Vec3 v )\n\t{\n\t\tvar known = new Dictionary\u003Cint, Vec2\u003E();\n\n\t\tfor ( var i = 0; i \u003C face.Count; i\u002B\u002B )\n\t\t\tknown[face.Indices[i]] = face.UVs[i];\n\n\t\t// Two corners are enough to fix a linear map from plane coordinates to UV, provided they are\n\t\t// not the same point. Anything less and the face was degenerate before this touched it.\n\t\tvar uvs = new Vec2[walk.Count];\n\t\tvar origin = Flatten( mesh.Positions[face.Indices[0]], u, v );\n\t\tvar originUv = face.UVs[0];\n\n\t\tvar scaleU = 0f;\n\t\tvar scaleV = 0f;\n\n\t\tfor ( var i = 1; i \u003C face.Count; i\u002B\u002B )\n\t\t{\n\t\t\tvar p = Flatten( mesh.Positions[face.Indices[i]], u, v ) - origin;\n\t\t\tvar duv = new Vec2( face.UVs[i].x - originUv.x, face.UVs[i].y - originUv.y );\n\n\t\t\tif ( MathF.Abs( p.x ) \u003E 1e-6f \u0026\u0026 scaleU == 0f )\n\t\t\t\tscaleU = duv.x / p.x;\n\n\t\t\tif ( MathF.Abs( p.y ) \u003E 1e-6f \u0026\u0026 scaleV == 0f )\n\t\t\t\tscaleV = duv.y / p.y;\n\t\t}\n\n\t\tfor ( var i = 0; i \u003C walk.Count; i\u002B\u002B )\n\t\t{\n\t\t\tif ( known.TryGetValue( walk[i], out var existing ) )\n\t\t\t{\n\t\t\t\tuvs[i] = existing;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar p = Flatten( mesh.Positions[walk[i]], u, v ) - origin;\n\t\t\tuvs[i] = new Vec2( originUv.x \u002B p.x * scaleU, originUv.y \u002B p.y * scaleV );\n\t\t}\n\n\t\treturn uvs;\n\t}\n\n\t/// \u003Csummary\u003EIndex of the face edge this point lies on, or -1.\u003C/summary\u003E\n\tstatic int EdgeCarrying( PolyMesh mesh, Face face, int vertex, Vec3 u, Vec3 v )\n\t{\n\t\tvar p = Flatten( mesh.Positions[vertex], u, v );\n\n\t\tfor ( var i = 0; i \u003C face.Count; i\u002B\u002B )\n\t\t{\n\t\t\tvar a = Flatten( mesh.Positions[face.Indices[i]], u, v );\n\t\t\tvar b = Flatten( mesh.Positions[face.Indices[(i \u002B 1) % face.Count]], u, v );\n\n\t\t\tif ( OnSegment( a, b, p ) )\n\t\t\t\treturn i;\n\t\t}\n\n\t\treturn -1;\n\t}\n\n\tstatic List\u003CVec2\u003E Polygon( PolyMesh mesh, Face face, Vec3 u, Vec3 v )\n\t{\n\t\tvar polygon = new List\u003CVec2\u003E( face.Count );\n\n\t\tforeach ( var index in face.Indices )\n\t\t\tpolygon.Add( Flatten( mesh.Positions[index], u, v ) );\n\n\t\treturn polygon;\n\t}\n\n\tstatic bool OnBoundary( List\u003CVec2\u003E polygon, Vec2 p )\n\t{\n\t\tfor ( var i = 0; i \u003C polygon.Count; i\u002B\u002B )\n\t\t{\n\t\t\tif ( OnSegment( polygon[i], polygon[(i \u002B 1) % polygon.Count], p ) )\n\t\t\t\treturn true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\tstatic bool OnSegment( Vec2 a, Vec2 b, Vec2 p )\n\t{\n\t\tvar along = b - a;\n\t\tvar length = along.Length;\n\n\t\tif ( length \u003C 1e-9f )\n\t\t\treturn (p - a).Length \u003C 1e-5f;\n\n\t\tvar cross = along.x * (p.y - a.y) - along.y * (p.x - a.x);\n\n\t\tif ( MathF.Abs( cross ) / length \u003E 1e-4f )\n\t\t\treturn false;\n\n\t\tvar t = ((p.x - a.x) * along.x \u002B (p.y - a.y) * along.y) / (length * length);\n\t\treturn t \u003E= -1e-5f \u0026\u0026 t \u003C= 1f \u002B 1e-5f;\n\t}\n\n\t// --- shared with MeshHoleRepair, kept here so this file stands alone -------------------------\n\n\tstatic List\u003CList\u003Cint\u003E\u003E BoundaryLoops( PolyMesh mesh )\n\t{\n\t\tvar atVertex = new Dictionary\u003Cint, List\u003Cint\u003E\u003E();\n\n\t\tforeach ( var (key, faces) in mesh.BuildEdgeFaces() )\n\t\t{\n\t\t\tif ( faces.Count != 1 )\n\t\t\t\tcontinue;\n\n\t\t\tLink( key.A, key.B );\n\t\t\tLink( key.B, key.A );\n\t\t}\n\n\t\tvar loops = new List\u003CList\u003Cint\u003E\u003E();\n\t\tvar used = new HashSet\u003CEdgeKey\u003E();\n\n\t\tforeach ( var start in atVertex.Keys )\n\t\t{\n\t\t\tif ( atVertex[start].Count != 2 )\n\t\t\t\tcontinue;\n\n\t\t\tvar loop = new List\u003Cint\u003E();\n\t\t\tvar current = start;\n\t\t\tvar previous = -1;\n\n\t\t\twhile ( true )\n\t\t\t{\n\t\t\t\tloop.Add( current );\n\n\t\t\t\tvar next = -1;\n\n\t\t\t\tforeach ( var candidate in atVertex[current] )\n\t\t\t\t{\n\t\t\t\t\tif ( candidate == previous || used.Contains( new EdgeKey( current, candidate ) ) )\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\tnext = candidate;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\n\t\t\t\tif ( next \u003C 0 )\n\t\t\t\t\tbreak;\n\n\t\t\t\tused.Add( new EdgeKey( current, next ) );\n\t\t\t\tprevious = current;\n\t\t\t\tcurrent = next;\n\n\t\t\t\tif ( current == start )\n\t\t\t\t\tbreak;\n\t\t\t}\n\n\t\t\tif ( loop.Count \u003E= 3 \u0026\u0026 current == start )\n\t\t\t\tloops.Add( loop );\n\t\t}\n\n\t\treturn loops;\n\n\t\tvoid Link( int from, int to )\n\t\t{\n\t\t\tif ( !atVertex.TryGetValue( from, out var list ) )\n\t\t\t{\n\t\t\t\tlist = new List\u003Cint\u003E( 2 );\n\t\t\t\tatVertex[from] = list;\n\t\t\t}\n\n\t\t\tlist.Add( to );\n\t\t}\n\t}\n\n\tstatic bool SharesAnyVertex( Face face, List\u003Cint\u003E loop )\n\t{\n\t\tforeach ( var index in face.Indices )\n\t\t{\n\t\t\tif ( loop.Contains( index ) )\n\t\t\t\treturn true;\n\t\t}\n\n\t\treturn false;\n\t}\n\n\tstatic Vec3 LoopNormal( PolyMesh mesh, List\u003Cint\u003E loop )\n\t{\n\t\tvar n = new Vec3( 0, 0, 0 );\n\n\t\tfor ( var i = 0; i \u003C loop.Count; i\u002B\u002B )\n\t\t{\n\t\t\tvar a = mesh.Positions[loop[i]];\n\t\t\tvar b = mesh.Positions[loop[(i \u002B 1) % loop.Count]];\n\n\t\t\tn \u002B= new Vec3(\n\t\t\t\t(a.y - b.y) * (a.z \u002B b.z),\n\t\t\t\t(a.z - b.z) * (a.x \u002B b.x),\n\t\t\t\t(a.x - b.x) * (a.y \u002B b.y) );\n\t\t}\n\n\t\treturn n;\n\t}\n\n\tstatic void Basis( Vec3 normal, out Vec3 u, out Vec3 v )\n\t{\n\t\tvar seed = MathF.Abs( normal.z ) \u003C 0.9f ? new Vec3( 0, 0, 1 ) : new Vec3( 1, 0, 0 );\n\n\t\tu = Vec3.Cross( seed, normal ).Normal;\n\t\tv = Vec3.Cross( normal, u ).Normal;\n\t}\n\n\tstatic Vec2 Flatten( Vec3 p, Vec3 u, Vec3 v ) =\u003E new( Vec3.Dot( p, u ), Vec3.Dot( p, v ) );\n\n\tstatic bool PointInPolygon( List\u003CVec2\u003E polygon, Vec2 point )\n\t{\n\t\tvar inside = false;\n\n\t\tfor ( int i = 0, j = polygon.Count - 1; i \u003C polygon.Count; j = i\u002B\u002B )\n\t\t{\n\t\t\tvar a = polygon[i];\n\t\t\tvar b = polygon[j];\n\n\t\t\tif ( a.y \u003E point.y != b.y \u003E point.y\n\t\t\t\t\u0026\u0026 point.x \u003C (b.x - a.x) * (point.y - a.y) / (b.y - a.y) \u002B a.x )\n\t\t\t{\n\t\t\t\tinside = !inside;\n\t\t\t}\n\t\t}\n\n\t\treturn inside;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Primitives.cs","FileName":"Primitives.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// Welds vertices by position so shared corners become one index rather than several coincident\n/// ones.\n///\n/// This matters more than it looks. A mesh built face-by-face without welding is topologically a\n/// pile of loose quads \u2014 every edge reads as a boundary, Catmull-Clark treats the whole thing as\n/// border, and the result falls apart into disconnected sheets. Anything constructed here goes\n/// through the welder.\n/// \u003C/summary\u003E\nsealed class VertexWelder\n{\n\treadonly Dictionary\u003C(long, long, long), int\u003E _map = new();\n\treadonly PolyMesh _mesh;\n\treadonly float _scale;\n\n\tpublic VertexWelder( PolyMesh mesh, float tolerance = 1e-5f )\n\t{\n\t\t_mesh = mesh;\n\t\t_scale = 1f / tolerance;\n\t}\n\n\tpublic int Add( Vec3 p )\n\t{\n\t\t// Quantising to an integer lattice, rather than comparing floats pairwise, keeps this O(1)\n\t\t// per vertex. Two positions closer than the tolerance land in the same bucket.\n\t\tvar key = (\n\t\t\t(long)MathF.Round( p.x * _scale ),\n\t\t\t(long)MathF.Round( p.y * _scale ),\n\t\t\t(long)MathF.Round( p.z * _scale ));\n\n\t\tif ( _map.TryGetValue( key, out var existing ) )\n\t\t\treturn existing;\n\n\t\tvar index = _mesh.AddVertex( p );\n\t\t_map[key] = index;\n\t\treturn index;\n\t}\n}\n\n/// \u003Csummary\u003E\n/// Parametric primitives, all quad-dominant on purpose.\n///\n/// QUADS ARE A HARD REQUIREMENT, NOT A PREFERENCE. These feed Catmull-Clark, which turns clean\n/// quads into a clean subdivision surface and turns triangle soup into a lumpy one. That is why\n/// there is no UV sphere here \u2014 its pole fans are triangles and they pinch visibly under a sculpt\n/// brush. QuadSphere costs nothing extra and has no poles.\n///\n/// Every primitive is closed and manifold, which is what lets collision fall out of the primitive\n/// list later instead of needing convex decomposition of the triangles.\n/// \u003C/summary\u003E\npublic static class Primitives\n{\n\t/// \u003Csummary\u003EAxis-aligned box. 8 vertices, 6 quads, one UV island per face.\u003C/summary\u003E\n\tpublic static PolyMesh Box( float sizeX = 1f, float sizeY = 1f, float sizeZ = 1f, int material = 0 )\n\t{\n\t\tvar hx = sizeX * 0.5f;\n\t\tvar hy = sizeY * 0.5f;\n\t\tvar hz = sizeZ * 0.5f;\n\n\t\tvar m = new PolyMesh();\n\n\t\t//   0..3 bottom (z-), 4..7 top (z\u002B), counter-clockwise seen from \u002BZ\n\t\tm.AddVertex( new Vec3( -hx, -hy, -hz ) );\n\t\tm.AddVertex( new Vec3( hx, -hy, -hz ) );\n\t\tm.AddVertex( new Vec3( hx, hy, -hz ) );\n\t\tm.AddVertex( new Vec3( -hx, hy, -hz ) );\n\t\tm.AddVertex( new Vec3( -hx, -hy, hz ) );\n\t\tm.AddVertex( new Vec3( hx, -hy, hz ) );\n\t\tm.AddVertex( new Vec3( hx, hy, hz ) );\n\t\tm.AddVertex( new Vec3( -hx, hy, hz ) );\n\n\t\tvar uv = new[] { new Vec2( 0, 0 ), new Vec2( 1, 0 ), new Vec2( 1, 1 ), new Vec2( 0, 1 ) };\n\n\t\t// Winding is chosen so each face\u0027s Newell normal points out of the box. Verified by test.\n\t\tm.AddFace( new[] { 0, 3, 2, 1 }, (Vec2[])uv.Clone(), material ); // -Z\n\t\tm.AddFace( new[] { 4, 5, 6, 7 }, (Vec2[])uv.Clone(), material ); // \u002BZ\n\t\tm.AddFace( new[] { 0, 1, 5, 4 }, (Vec2[])uv.Clone(), material ); // -Y\n\t\tm.AddFace( new[] { 1, 2, 6, 5 }, (Vec2[])uv.Clone(), material ); // \u002BX\n\t\tm.AddFace( new[] { 2, 3, 7, 6 }, (Vec2[])uv.Clone(), material ); // \u002BY\n\t\tm.AddFace( new[] { 3, 0, 4, 7 }, (Vec2[])uv.Clone(), material ); // -X\n\n\t\treturn m;\n\t}\n\n\t/// \u003Csummary\u003EFlat grid in XY. Open \u2014 this is the one primitive with a boundary, which makes it\n\t/// the useful case for testing the boundary rules in Catmull-Clark.\u003C/summary\u003E\n\tpublic static PolyMesh Plane( float sizeX = 1f, float sizeY = 1f, int segmentsX = 1, int segmentsY = 1, int material = 0 )\n\t{\n\t\tsegmentsX = Math.Max( 1, segmentsX );\n\t\tsegmentsY = Math.Max( 1, segmentsY );\n\n\t\tvar m = new PolyMesh();\n\n\t\tfor ( var iy = 0; iy \u003C= segmentsY; iy\u002B\u002B )\n\t\t{\n\t\t\tfor ( var ix = 0; ix \u003C= segmentsX; ix\u002B\u002B )\n\t\t\t{\n\t\t\t\tm.AddVertex( new Vec3(\n\t\t\t\t\t(ix / (float)segmentsX - 0.5f) * sizeX,\n\t\t\t\t\t(iy / (float)segmentsY - 0.5f) * sizeY,\n\t\t\t\t\t0f ) );\n\t\t\t}\n\t\t}\n\n\t\tvar stride = segmentsX \u002B 1;\n\n\t\tfor ( var iy = 0; iy \u003C segmentsY; iy\u002B\u002B )\n\t\t{\n\t\t\tfor ( var ix = 0; ix \u003C segmentsX; ix\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar a = iy * stride \u002B ix;\n\t\t\t\tvar b = a \u002B 1;\n\t\t\t\tvar c = a \u002B stride \u002B 1;\n\t\t\t\tvar d = a \u002B stride;\n\n\t\t\t\tvar u0 = ix / (float)segmentsX;\n\t\t\t\tvar u1 = (ix \u002B 1) / (float)segmentsX;\n\t\t\t\tvar v0 = iy / (float)segmentsY;\n\t\t\t\tvar v1 = (iy \u002B 1) / (float)segmentsY;\n\n\t\t\t\tm.AddFace(\n\t\t\t\t\tnew[] { a, b, c, d },\n\t\t\t\t\tnew[] { new Vec2( u0, v0 ), new Vec2( u1, v0 ), new Vec2( u1, v1 ), new Vec2( u0, v1 ) },\n\t\t\t\t\tmaterial );\n\t\t\t}\n\t\t}\n\n\t\treturn m;\n\t}\n\n\t/// \u003Csummary\u003ECylinder about Z. Sides are quads; the caps are single n-gons rather than triangle\n\t/// fans, because Catmull-Clark turns an n-gon into n clean quads and turns a fan into a mess\n\t/// around a high-valence hub.\u003C/summary\u003E\n\tpublic static PolyMesh Cylinder( float radius = 0.5f, float height = 1f, int segments = 16, int material = 0 )\n\t{\n\t\tsegments = Math.Max( 3, segments );\n\n\t\tvar m = new PolyMesh();\n\t\tvar hz = height * 0.5f;\n\n\t\tfor ( var i = 0; i \u003C segments; i\u002B\u002B )\n\t\t{\n\t\t\tvar a = i / (float)segments * MathF.Tau;\n\t\t\tm.AddVertex( new Vec3( MathF.Cos( a ) * radius, MathF.Sin( a ) * radius, -hz ) );\n\t\t}\n\n\t\tfor ( var i = 0; i \u003C segments; i\u002B\u002B )\n\t\t{\n\t\t\tvar a = i / (float)segments * MathF.Tau;\n\t\t\tm.AddVertex( new Vec3( MathF.Cos( a ) * radius, MathF.Sin( a ) * radius, hz ) );\n\t\t}\n\n\t\tfor ( var i = 0; i \u003C segments; i\u002B\u002B )\n\t\t{\n\t\t\tvar next = (i \u002B 1) % segments;\n\t\t\tvar u0 = i / (float)segments;\n\t\t\tvar u1 = (i \u002B 1) / (float)segments;\n\n\t\t\tm.AddFace(\n\t\t\t\tnew[] { i, next, segments \u002B next, segments \u002B i },\n\t\t\t\tnew[] { new Vec2( u0, 0 ), new Vec2( u1, 0 ), new Vec2( u1, 1 ), new Vec2( u0, 1 ) },\n\t\t\t\tmaterial );\n\t\t}\n\n\t\t// Bottom cap runs backwards so its normal points -Z.\n\t\tvar bottom = new int[segments];\n\t\tvar bottomUV = new Vec2[segments];\n\n\t\tfor ( var i = 0; i \u003C segments; i\u002B\u002B )\n\t\t{\n\t\t\tbottom[i] = segments - 1 - i;\n\t\t\tvar a = (segments - 1 - i) / (float)segments * MathF.Tau;\n\t\t\tbottomUV[i] = new Vec2( MathF.Cos( a ) * 0.5f \u002B 0.5f, MathF.Sin( a ) * 0.5f \u002B 0.5f );\n\t\t}\n\n\t\tm.AddFace( bottom, bottomUV, material );\n\n\t\tvar top = new int[segments];\n\t\tvar topUV = new Vec2[segments];\n\n\t\tfor ( var i = 0; i \u003C segments; i\u002B\u002B )\n\t\t{\n\t\t\ttop[i] = segments \u002B i;\n\t\t\tvar a = i / (float)segments * MathF.Tau;\n\t\t\ttopUV[i] = new Vec2( MathF.Cos( a ) * 0.5f \u002B 0.5f, MathF.Sin( a ) * 0.5f \u002B 0.5f );\n\t\t}\n\n\t\tm.AddFace( top, topUV, material );\n\n\t\treturn m;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Sphere built as a subdivided cube projected onto the sphere.\n\t///\n\t/// Deliberately not a UV sphere. A UV sphere concentrates a triangle fan at each pole and\n\t/// crowds vertices near them; both artefacts survive every subdivision level and show up the\n\t/// first time anyone drags a brush across a pole. A quad sphere is all quads with only eight\n\t/// valence-3 vertices, spread evenly.\n\t/// \u003C/summary\u003E\n\tpublic static PolyMesh QuadSphere( float radius = 0.5f, int segments = 4, int material = 0 )\n\t{\n\t\tsegments = Math.Max( 1, segments );\n\n\t\tvar m = new PolyMesh();\n\t\tvar weld = new VertexWelder( m );\n\n\t\t// The six cube faces, each as an origin corner plus two edge vectors.\n\t\tvar faces = new (Vec3 Origin, Vec3 U, Vec3 V)[]\n\t\t{\n\t\t\t(new Vec3( -1, -1, -1 ), new Vec3( 0, 2, 0 ), new Vec3( 2, 0, 0 )),  // -Z\n\t\t\t(new Vec3( -1, -1, 1 ), new Vec3( 2, 0, 0 ), new Vec3( 0, 2, 0 )),   // \u002BZ\n\t\t\t(new Vec3( -1, -1, -1 ), new Vec3( 2, 0, 0 ), new Vec3( 0, 0, 2 )),  // -Y\n\t\t\t(new Vec3( 1, -1, -1 ), new Vec3( 0, 2, 0 ), new Vec3( 0, 0, 2 )),   // \u002BX\n\t\t\t(new Vec3( 1, 1, -1 ), new Vec3( -2, 0, 0 ), new Vec3( 0, 0, 2 )),   // \u002BY\n\t\t\t(new Vec3( -1, 1, -1 ), new Vec3( 0, -2, 0 ), new Vec3( 0, 0, 2 )),  // -X\n\t\t};\n\n\t\tforeach ( var (origin, uDir, vDir) in faces )\n\t\t{\n\t\t\tfor ( var iv = 0; iv \u003C segments; iv\u002B\u002B )\n\t\t\t{\n\t\t\t\tfor ( var iu = 0; iu \u003C segments; iu\u002B\u002B )\n\t\t\t\t{\n\t\t\t\t\tvar corners = new int[4];\n\t\t\t\t\tvar uvs = new Vec2[4];\n\t\t\t\t\tvar offsets = new[] { (0, 0), (1, 0), (1, 1), (0, 1) };\n\n\t\t\t\t\tfor ( var c = 0; c \u003C 4; c\u002B\u002B )\n\t\t\t\t\t{\n\t\t\t\t\t\tvar (du, dv) = offsets[c];\n\t\t\t\t\t\tvar fu = (iu \u002B du) / (float)segments;\n\t\t\t\t\t\tvar fv = (iv \u002B dv) / (float)segments;\n\n\t\t\t\t\t\t// Project the cube point onto the sphere by normalising it.\n\t\t\t\t\t\tvar cube = origin \u002B uDir * fu \u002B vDir * fv;\n\t\t\t\t\t\tcorners[c] = weld.Add( cube.Normal * radius );\n\t\t\t\t\t\tuvs[c] = new Vec2( fu, fv );\n\t\t\t\t\t}\n\n\t\t\t\t\tm.AddFace( corners, uvs, material );\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\treturn m;\n\t}\n\n\t/// \u003Csummary\u003ERight-angle wedge \u2014 a ramp. The two ends are triangles, which is unavoidable for\n\t/// this shape; everything else is quads.\u003C/summary\u003E\n\tpublic static PolyMesh Wedge( float sizeX = 1f, float sizeY = 1f, float sizeZ = 1f, int material = 0 )\n\t{\n\t\tvar hx = sizeX * 0.5f;\n\t\tvar hy = sizeY * 0.5f;\n\t\tvar hz = sizeZ * 0.5f;\n\n\t\tvar m = new PolyMesh();\n\n\t\tm.AddVertex( new Vec3( -hx, -hy, -hz ) ); // 0\n\t\tm.AddVertex( new Vec3( hx, -hy, -hz ) );  // 1\n\t\tm.AddVertex( new Vec3( hx, hy, -hz ) );   // 2\n\t\tm.AddVertex( new Vec3( -hx, hy, -hz ) );  // 3\n\t\tm.AddVertex( new Vec3( -hx, -hy, hz ) );  // 4  high edge, -Y side\n\t\tm.AddVertex( new Vec3( -hx, hy, hz ) );   // 5  high edge, \u002BY side\n\n\t\tvar quadUV = new[] { new Vec2( 0, 0 ), new Vec2( 1, 0 ), new Vec2( 1, 1 ), new Vec2( 0, 1 ) };\n\t\tvar triUV = new[] { new Vec2( 0, 0 ), new Vec2( 1, 0 ), new Vec2( 0, 1 ) };\n\n\t\tm.AddFace( new[] { 0, 3, 2, 1 }, (Vec2[])quadUV.Clone(), material ); // base, -Z\n\t\tm.AddFace( new[] { 1, 2, 5, 4 }, (Vec2[])quadUV.Clone(), material ); // slope\n\t\tm.AddFace( new[] { 3, 0, 4, 5 }, (Vec2[])quadUV.Clone(), material ); // back, -X\n\t\tm.AddFace( new[] { 0, 1, 4 }, (Vec2[])triUV.Clone(), material );     // -Y end\n\t\tm.AddFace( new[] { 2, 3, 5 }, (Vec2[])triUV.Clone(), material );     // \u002BY end\n\n\t\treturn m;\n\t}\n\n\t/// \u003Csummary\u003EHollow tube about Z. All quads, closed, and a good stress case for the validator\n\t/// because it is genus 1 \u2014 Euler characteristic 0, not 2.\u003C/summary\u003E\n\tpublic static PolyMesh Tube( float outerRadius = 0.5f, float innerRadius = 0.3f, float height = 1f, int segments = 16, int material = 0 )\n\t{\n\t\tsegments = Math.Max( 3, segments );\n\n\t\tif ( innerRadius \u003E= outerRadius )\n\t\t\tthrow new ArgumentException( \u0022innerRadius must be smaller than outerRadius\u0022 );\n\n\t\tvar m = new PolyMesh();\n\t\tvar hz = height * 0.5f;\n\n\t\t// Four rings: outer-bottom, outer-top, inner-bottom, inner-top.\n\t\tvoid Ring( float radius, float z )\n\t\t{\n\t\t\tfor ( var i = 0; i \u003C segments; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar a = i / (float)segments * MathF.Tau;\n\t\t\t\tm.AddVertex( new Vec3( MathF.Cos( a ) * radius, MathF.Sin( a ) * radius, z ) );\n\t\t\t}\n\t\t}\n\n\t\tRing( outerRadius, -hz ); // 0\n\t\tRing( outerRadius, hz );  // 1\n\t\tRing( innerRadius, -hz ); // 2\n\t\tRing( innerRadius, hz );  // 3\n\n\t\tvar ob = 0;\n\t\tvar ot = segments;\n\t\tvar ib = segments * 2;\n\t\tvar it = segments * 3;\n\n\t\tVec2[] UV( int i ) =\u003E new[]\n\t\t{\n\t\t\tnew Vec2( i / (float)segments, 0 ),\n\t\t\tnew Vec2( (i \u002B 1) / (float)segments, 0 ),\n\t\t\tnew Vec2( (i \u002B 1) / (float)segments, 1 ),\n\t\t\tnew Vec2( i / (float)segments, 1 )\n\t\t};\n\n\t\tfor ( var i = 0; i \u003C segments; i\u002B\u002B )\n\t\t{\n\t\t\tvar n = (i \u002B 1) % segments;\n\n\t\t\tm.AddFace( new[] { ob \u002B i, ob \u002B n, ot \u002B n, ot \u002B i }, UV( i ), material );  // outer wall\n\t\t\tm.AddFace( new[] { ib \u002B n, ib \u002B i, it \u002B i, it \u002B n }, UV( i ), material );  // inner wall, reversed\n\t\t\tm.AddFace( new[] { ot \u002B i, ot \u002B n, it \u002B n, it \u002B i }, UV( i ), material );  // top annulus\n\t\t\tm.AddFace( new[] { ib \u002B i, ib \u002B n, ob \u002B n, ob \u002B i }, UV( i ), material );  // bottom annulus\n\t\t}\n\n\t\treturn m;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Editor/EffigyEditor/EffigyPreview.cs","FileName":"EffigyPreview.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using Effigy;\r\nusing Sandbox;\r\nusing System;\r\nusing System.Collections.Generic;\r\n\r\nnamespace Marionette.EditorTools;\r\n\r\n/// \u003Csummary\u003E\r\n/// Turns the Part Studio\u0027s in-memory PolyMesh straight into a runtime Model, so the viewport can\r\n/// show the result of a feature the moment it is added.\r\n///\r\n/// The alternative \u2014 the path Export/Compile still take \u2014 is OBJ on disk, a generated .vmdl, and\r\n/// a call into the asset compiler. That takes hundreds of milliseconds and writes files, which is\r\n/// fine for producing a placeable prop and hopeless as the response to dragging a slider. This\r\n/// path never touches the disk.\r\n///\r\n/// It is deliberately NOT a second geometry pipeline: it shares MeshNormals with ObjWriter, so\r\n/// what you see here and what the compiler bakes are smoothed by the same rule.\r\n///\r\n/// MATERIALS RENDER HERE NOW. A face carries a slot number and PartStudio.MaterialNames binds the\r\n/// slot to a vmat, so the preview groups faces by the material they resolve to and builds one\r\n/// submesh per material \u2014 the same grouping the exporters express as usemtl runs. Faces on slot 0,\r\n/// on a slot nothing is bound to, or on a slot whose vmat will not load fall back to the flat\r\n/// placeholder rather than rendering as nothing. Pass no resolver \u2014 the sculpt preview does \u2014 and\r\n/// the whole model is the placeholder, which is the old behaviour.\r\n/// \u003C/summary\u003E\r\ninternal static class EffigyPreview\r\n{\r\n\t/// \u003Csummary\u003E\r\n\t/// FLAT grey, with no pattern on it at all. The fallback for a face whose slot names no\r\n\t/// material, or names one that will not load.\r\n\t///\r\n\t/// It used to be dev/reflectivity_30, and that material actively lies about scale: its texture\r\n\t/// is a grid with the number \u002230\u0022 printed in every tile - the material\u0027s reflectivity, nothing\r\n\t/// to do with size - and caps take plane coordinates straight through as UVs, so it tiles once\r\n\t/// per sketch unit. A 30x30 face therefore came out covered in thirty-odd squares each labelled\r\n\t/// \u002230\u0022, which reads as a part 900 units across. gray_50\u0027s texture is a single flat colour.\r\n\t/// \u003C/summary\u003E\r\n\tprivate const string PreviewMaterial = \u0022materials/dev/gray_50.vmat\u0022;\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Build a Model from the mesh, optionally resolving each face\u0027s material slot to a real vmat.\r\n\t/// \u003C/summary\u003E\r\n\t/// \u003Cparam name=\u0022materialForSlot\u0022\u003ESlot number to bound material path, or null / empty for an\r\n\t/// unbound slot. Pass null to render everything on the placeholder.\u003C/param\u003E\r\n\tpublic static Model Build( PolyMesh mesh, Func\u003Cint, string\u003E materialForSlot = null,\r\n\t\tfloat smoothingAngleDegrees = MeshNormals.DefaultSmoothingAngleDegrees )\r\n\t{\r\n\t\tif ( mesh is null || mesh.FaceCount == 0 || mesh.VertexCount == 0 )\r\n\t\t\treturn null;\r\n\r\n\t\tvar (cornerNormals, normals) = MeshNormals.ComputeCornerNormals( mesh, smoothingAngleDegrees );\r\n\r\n\t\tvar placeholder = Material.Load( PreviewMaterial );\r\n\t\tvar bounds = BoundsOf( mesh );\r\n\r\n\t\t// Faces bucketed by the material they render with. One drop of brushed steel onto three\r\n\t\t// faces is one bucket and one submesh; a part nobody assigned is one bucket on the\r\n\t\t// placeholder. The slot is resolved once and cached, not once per face.\r\n\t\tvar buckets = new Dictionary\u003CMaterial, List\u003Cint\u003E\u003E();\r\n\t\tvar slotCache = new Dictionary\u003Cint, Material\u003E();\r\n\r\n\t\tfor ( var fi = 0; fi \u003C mesh.FaceCount; fi\u002B\u002B )\r\n\t\t{\r\n\t\t\tif ( mesh.Faces[fi].Count \u003C 3 )\r\n\t\t\t\tcontinue;\r\n\r\n\t\t\tvar material = ResolveMaterial( mesh.Faces[fi].Material, materialForSlot, placeholder, slotCache );\r\n\r\n\t\t\tif ( !buckets.TryGetValue( material, out var faces ) )\r\n\t\t\t\tbuckets[material] = faces = new List\u003Cint\u003E();\r\n\r\n\t\t\tfaces.Add( fi );\r\n\t\t}\r\n\r\n\t\tvar builder = Model.Builder;\r\n\t\tvar any = false;\r\n\r\n\t\tforeach ( var (material, faces) in buckets )\r\n\t\t{\r\n\t\t\tif ( BuildSubmesh( mesh, faces, cornerNormals, normals, material, bounds ) is { } sub )\r\n\t\t\t{\r\n\t\t\t\tbuilder.AddMesh( sub );\r\n\t\t\t\tany = true;\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\treturn any ? builder.Create() : null;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// The material a slot renders with: the vmat bound to it, or the flat placeholder.\r\n\t///\r\n\t/// Slot 0 is the slot every face starts on and never carries a material, so it is the\r\n\t/// placeholder without a lookup. A named slot whose vmat will not load \u2014 a path into a package\r\n\t/// that is not installed, a material deleted since it was assigned \u2014 also falls back rather\r\n\t/// than rendering the part as nothing, which is the failure the single-material preview used\r\n\t/// to avoid wholesale.\r\n\t/// \u003C/summary\u003E\r\n\tprivate static Material ResolveMaterial( int slot, Func\u003Cint, string\u003E materialForSlot,\r\n\t\tMaterial placeholder, Dictionary\u003Cint, Material\u003E cache )\r\n\t{\r\n\t\tif ( slot \u003C= 0 || materialForSlot is null )\r\n\t\t\treturn placeholder;\r\n\r\n\t\tif ( cache.TryGetValue( slot, out var cached ) )\r\n\t\t\treturn cached;\r\n\r\n\t\tvar name = materialForSlot( slot );\r\n\t\tvar material = string.IsNullOrWhiteSpace( name ) ? null : Material.Load( name );\r\n\r\n\t\t// A named slot whose vmat would not load is the one case the fallback hides \u2014 the face\r\n\t\t// renders as placeholder grey and nothing says why. Say why, once per slot per build.\r\n\t\tif ( material is null \u0026\u0026 !string.IsNullOrWhiteSpace( name ) )\r\n\t\t\tLog.Warning( $\u0022[effigy-preview] slot {slot}: material \u0027{name}\u0027 failed to load \u2014 using placeholder\u0022 );\r\n\r\n\t\treturn cache[slot] = material ?? placeholder;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EOne Mesh over a subset of the faces, all sharing one material.\u003C/summary\u003E\r\n\tprivate static Mesh BuildSubmesh( PolyMesh mesh, List\u003Cint\u003E faceIndices, int[][] cornerNormals,\r\n\t\tList\u003CVec3\u003E normals, Material material, BBox bounds )\r\n\t{\r\n\t\t// One vertex per face corner rather than per position. Corner normals are the whole point\r\n\t\t// of MeshNormals - sharing a vertex between two faces that disagree about the normal is\r\n\t\t// exactly what rounds off a box\u0027s edges.\r\n\t\tvar vertices = new List\u003CSimpleVertex\u003E( faceIndices.Count * 4 );\r\n\t\tvar indices = new List\u003Cint\u003E( faceIndices.Count * 6 );\r\n\r\n\t\tforeach ( var fi in faceIndices )\r\n\t\t{\r\n\t\t\tvar face = mesh.Faces[fi];\r\n\t\t\tvar corners = cornerNormals[fi];\r\n\r\n\t\t\tvar first = vertices.Count;\r\n\r\n\t\t\tfor ( var c = 0; c \u003C face.Count; c\u002B\u002B )\r\n\t\t\t{\r\n\t\t\t\tvar p = mesh.Positions[face.Indices[c]];\r\n\t\t\t\tvar n = normals[corners[c]];\r\n\t\t\t\tvar uv = face.UVs is not null \u0026\u0026 c \u003C face.UVs.Length ? face.UVs[c] : default;\r\n\r\n\t\t\t\tvar position = new Vector3( p.x, p.y, p.z );\r\n\t\t\t\tvar normal = new Vector3( n.x, n.y, n.z );\r\n\r\n\t\t\t\tvertices.Add( new SimpleVertex( position, normal, TangentFor( normal ), new Vector2( uv.x, uv.y ) ) );\r\n\t\t\t}\r\n\r\n\t\t\t// EAR CLIPPING, NOT A FAN. This used to fan from corner 0 on the grounds that every\r\n\t\t\t// face the kernel produces is convex. Extrude caps are not: they are whatever closed\r\n\t\t\t// region was drawn, and fanning a concave one fills its notches in - draw a dart and\r\n\t\t\t// the solid came back as a quadrilateral with the concave corner swallowed.\r\n\t\t\tvar polygon = new List\u003CVec3\u003E( face.Count );\r\n\r\n\t\t\tfor ( var k = 0; k \u003C face.Count; k\u002B\u002B )\r\n\t\t\t\tpolygon.Add( mesh.Positions[face.Indices[k]] );\r\n\r\n\t\t\tforeach ( var (a, b, cc) in Triangulate.Face( polygon ) )\r\n\t\t\t{\r\n\t\t\t\tindices.Add( first \u002B a );\r\n\t\t\t\tindices.Add( first \u002B b );\r\n\t\t\t\tindices.Add( first \u002B cc );\r\n\t\t\t}\r\n\t\t}\r\n\r\n\t\tif ( indices.Count == 0 )\r\n\t\t\treturn null;\r\n\r\n\t\tvar sbMesh = new Mesh( material );\r\n\t\tsbMesh.CreateVertexBuffer\u003CSimpleVertex\u003E( vertices.Count, vertices );\r\n\t\tsbMesh.CreateIndexBuffer( indices.Count, indices );\r\n\t\tsbMesh.Bounds = bounds;\r\n\r\n\t\treturn sbMesh;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// Any unit vector perpendicular to the normal will do. Effigy has no tangent basis of its own\r\n\t/// - UVs come from box or planar projection, not from an unwrap - so there is nothing to\r\n\t/// derive a real tangent from, and the preview material does not read one.\r\n\t/// \u003C/summary\u003E\r\n\tprivate static Vector3 TangentFor( Vector3 normal )\r\n\t{\r\n\t\t// Cross with whichever axis the normal is least aligned to, so the result never collapses.\r\n\t\tvar axis = MathF.Abs( normal.z ) \u003C 0.9f ? Vector3.Up : Vector3.Forward;\r\n\t\tvar tangent = Vector3.Cross( normal, axis );\r\n\r\n\t\treturn tangent.IsNearZeroLength ? Vector3.Forward : tangent.Normal;\r\n\t}\r\n\r\n\tprivate static BBox BoundsOf( PolyMesh mesh )\r\n\t{\r\n\t\tvar min = new Vector3( float.MaxValue );\r\n\t\tvar max = new Vector3( float.MinValue );\r\n\r\n\t\tforeach ( var p in mesh.Positions )\r\n\t\t{\r\n\t\t\tvar v = new Vector3( p.x, p.y, p.z );\r\n\t\t\tmin = Vector3.Min( min, v );\r\n\t\t\tmax = Vector3.Max( max, v );\r\n\t\t}\r\n\r\n\t\treturn new BBox( min, max );\r\n\t}\r\n}\r\n"},{"Ident":"pooh.geppetto","Path":"Editor/RigControlEditor/RigHelpBox.cs","FileName":"RigHelpBox.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using Editor;\r\nusing Sandbox;\r\n\r\nnamespace Marionette.Tools;\r\n\r\n/// \u003Csummary\u003E\r\n/// The \u0022Documentation\u0022 style collapsible note used throughout the stock Inspector (a chevron, a\r\n/// circled ? badge, a bold title, a blue accent line under the whole block) - reused here so each\r\n/// tab opens with the same look instead of a hand-rolled one-off.\r\n/// \u003C/summary\u003E\r\ninternal static class RigHelpBox\r\n{\r\n\t/// \u003Csummary\u003EOne labelled chunk of the note - \u0022Model\u0022, \u0022IK Constraint\u0022, whatever the reader\r\n\t/// needs to look up on its own rather than hunt for inside one wall of text.\u003C/summary\u003E\r\n\tpublic readonly record struct Section( string Heading, string Body );\r\n\r\n\tpublic static Section S( string heading, string body ) =\u003E new( heading, body );\r\n\r\n\t/// \u003Csummary\u003E\r\n\t/// intro: a plain, unlabelled paragraph at the top - what this tab is, in one or two\r\n\t/// sentences. sections: each gets its own bold heading and paragraph below it, so a reader\r\n\t/// can scan headings instead of parsing a block of text to find the one field they care about.\r\n\t///\r\n\t/// COLLAPSED BY DEFAULT. Expanded, three tabs of reference text is the first thing a new user\r\n\t/// sees, before they\u0027ve done anything for it to be about - which reads as homework and gets\r\n\t/// skipped wholesale, taking the useful parts with it. The status bar\u0027s tutorial handles\r\n\t/// \u0022what do I do now\u0022 one step at a time; this is the reference you open once you have a\r\n\t/// specific question, and the header line says which question it answers.\r\n\t/// \u003C/summary\u003E\r\n\tpublic static Widget Create( Widget parent, string intro, Section[] sections, bool startExpanded = false )\r\n\t{\r\n\t\tvar expanded = startExpanded;\r\n\r\n\t\tvar box = new Widget( parent ) { Layout = Layout.Column() };\r\n\t\tbox.Layout.Margin = 0;\r\n\r\n\t\tbox.OnPaintOverride = () =\u003E\r\n\t\t{\r\n\t\t\t// Accent line along the bottom, the one clearly-visible border the reference has.\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tPaint.SetBrush( Theme.Blue );\r\n\t\t\tPaint.DrawRect( new Rect( 0, box.Height - 2, box.Width, 2 ) );\r\n\t\t\treturn false;\r\n\t\t};\r\n\r\n\t\tvar header = new HeaderWidget( box ) { FixedHeight = 28 };\r\n\t\tbox.Layout.Add( header );\r\n\r\n\t\tvar body = new Widget( box ) { Layout = Layout.Column() };\r\n\t\tbody.Layout.Margin = new Sandbox.UI.Margin( 32, 0, 12, 12 );\r\n\t\tbody.Layout.Spacing = 10;\r\n\r\n\t\tif ( !string.IsNullOrWhiteSpace( intro ) )\r\n\t\t\tbody.Layout.Add( new Editor.Label( intro ) { WordWrap = true } );\r\n\r\n\t\tforeach ( var section in sections )\r\n\t\t{\r\n\t\t\tvar block = body.Layout.AddColumn();\r\n\t\t\tblock.Spacing = 2;\r\n\r\n\t\t\tvar heading = new Editor.Label( section.Heading ) { Color = Theme.Blue };\r\n\t\t\theading.SetStyles( \u0022font-weight: 600;\u0022 );\r\n\t\t\tblock.Add( heading );\r\n\r\n\t\t\tblock.Add( new Editor.Label( section.Body ) { WordWrap = true } );\r\n\t\t}\r\n\r\n\t\tbox.Layout.Add( body );\r\n\r\n\t\tvoid UpdateState()\r\n\t\t{\r\n\t\t\theader.Expanded = expanded;\r\n\t\t\tbody.Visible = expanded;\r\n\t\t}\r\n\r\n\t\tUpdateState();\r\n\r\n\t\theader.Clicked = () =\u003E\r\n\t\t{\r\n\t\t\texpanded = !expanded;\r\n\t\t\tUpdateState();\r\n\t\t};\r\n\r\n\t\treturn box;\r\n\t}\r\n\r\n\t/// \u003Csummary\u003EChevron \u002B circled \u0022?\u0022 \u002B bold \u0022Documentation\u0022 title, hand-painted so it actually\r\n\t/// matches the reference instead of approximating it with stock Label/Button widgets.\u003C/summary\u003E\r\n\tprivate sealed class HeaderWidget : Widget\r\n\t{\r\n\t\tpublic bool Expanded { get; set; } = true;\r\n\t\tpublic System.Action Clicked { get; set; }\r\n\r\n\t\tpublic HeaderWidget( Widget parent ) : base( parent )\r\n\t\t{\r\n\t\t\tCursor = CursorShape.Finger;\r\n\t\t}\r\n\r\n\t\tprotected override void OnPaint()\r\n\t\t{\r\n\t\t\tvar rect = LocalRect;\r\n\r\n\t\t\tPaint.Antialiasing = true;\r\n\r\n\t\t\t// Chevron\r\n\t\t\tPaint.ClearBrush();\r\n\t\t\tPaint.SetPen( Theme.Blue );\r\n\t\t\tPaint.DrawIcon( new Rect( 6, 0, 16, rect.Height ), Expanded ? \u0022expand_more\u0022 : \u0022chevron_right\u0022, 16, TextFlag.Center );\r\n\r\n\t\t\t// Circled \u0022?\u0022 badge - DrawCircle takes a Rect (confirmed from existing usage\r\n\t\t\t// elsewhere in this project), not a center\u002Bradius pair.\r\n\t\t\tvar badgeCenter = new Vector2( 34, rect.Center.y );\r\n\t\t\tPaint.ClearPen();\r\n\t\t\tPaint.SetBrush( Theme.Blue );\r\n\t\t\tPaint.DrawCircle( new Rect( badgeCenter.x - 8, badgeCenter.y - 8, 16, 16 ) );\r\n\r\n\t\t\tPaint.SetPen( Theme.WindowBackground );\r\n\t\t\tPaint.SetDefaultFont( 8, 700 );\r\n\t\t\tPaint.DrawText( new Rect( badgeCenter.x - 8, badgeCenter.y - 8, 16, 16 ), \u0022?\u0022, TextFlag.Center );\r\n\r\n\t\t\t// Title\r\n\t\t\tPaint.SetPen( Theme.Blue );\r\n\t\t\tPaint.SetDefaultFont( 8, 600 );\r\n\t\t\tPaint.DrawText( new Rect( 50, 0, rect.Width - 58, rect.Height ), \u0022Documentation\u0022, TextFlag.LeftCenter );\r\n\t\t}\r\n\r\n\t\tprotected override void OnMouseClick( MouseEvent e )\r\n\t\t{\r\n\t\t\tbase.OnMouseClick( e );\r\n\t\t\tClicked?.Invoke();\r\n\t\t}\r\n\t}\r\n}\r\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Features/MaterialDrop.cs","FileName":"MaterialDrop.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// Dropping a material onto a face.\n///\n/// THE PROBLEM THIS SOLVES. Faces carry a slot number, not a material \u2014 see FaceMaterialEdit for\n/// why that has to stay true \u2014 and PartStudio.MaterialNames maps the number to a name. Every\n/// existing way in names a slot you have already chosen: the Materials panel browses FOR slot 5,\n/// the face menu browses for the slot the face is already on. Dragging a material out of a browser\n/// and letting go over a face names no slot at all. It says \u0022this face, this material\u0022 and leaves\n/// the number entirely to us.\n///\n/// So this is the half that was missing: turn a material into the slot that should carry it, then\n/// do the ordinary face assignment with it. The rule is one slot per material, reused \u2014\n/// \u003Csee cref=\u0022SlotFor\u0022/\u003E hands back the slot that already carries the material if there is one, so\n/// dropping the same material on thirty faces produces one slot and one assignment feature rather\n/// than thirty of each. Only a material nobody has used yet takes a fresh slot.\n///\n/// AND IT PUTS BACK WHAT IT TOOK. A drop that moves a face off a slot nothing else is holding\n/// retires that slot\u0027s name too \u2014 see \u003Csee cref=\u0022ReleaseVacatedSlot\u0022/\u003E. Without it, changing your\n/// mind about one face is a one-way ratchet: the slot count only ever goes up, the rejected\n/// materials stay bound to slots no face wears, and the exporters write every one of them.\n///\n/// It edits the HISTORY, never the mesh, exactly as FaceMaterialEdit does, and for the same reason:\n/// bodies are remade from scratch on every rebuild.\n/// \u003C/summary\u003E\npublic static class MaterialDrop\n{\n\t/// \u003Csummary\u003EThe highest slot a face can be on \u2014 FaceMaterialFeature.Material clamps to 0..63,\n\t/// so a slot past this could be stored and would never come back.\u003C/summary\u003E\n\tpublic const int HighestSlot = 63;\n\n\t/// \u003Csummary\u003E\n\t/// Which slot should carry \u003Cparamref name=\u0022material\u0022/\u003E, or -1 when there is nowhere to put it.\n\t///\n\t/// Three answers, in order:\n\t///\n\t/// 1. THE SLOT ALREADY CARRYING IT. Checked first and by name, so a second drop of the same\n\t///    material joins the first rather than opening a second slot that renders identically. The\n\t///    lowest such slot wins if a document somehow named two, purely so the answer is stable.\n\t///\n\t/// 2. THE LOWEST SLOT NOBODY IS USING, counting from 1. Used means named OR painted on \u2014 a slot\n\t///    with an assignment feature and no name is the result of the face menu\u0027s \u0022put this face on\n\t///    slot 3\u0022, and taking it here would silently repaint those faces with the dropped material.\n\t///\n\t/// 3. NOTHING, when all 63 are spoken for.\n\t///\n\t/// SLOT 0 IS NEVER ALLOCATED, though it is returned by rule 1 if somebody has named it. It is\n\t/// the slot every face starts on and the one the viewport pointedly does not tint: handing it to\n\t/// a drop would paint the whole part instead of the one face under the cursor. Naming slot 0\n\t/// remains something you do deliberately, from the Materials panel, where the consequence is on\n\t/// screen next to it.\n\t/// \u003C/summary\u003E\n\tpublic static int SlotFor( PartStudio studio, string material )\n\t{\n\t\tif ( studio is null )\n\t\t\treturn -1;\n\n\t\tif ( Normalise( material ) is null )\n\t\t\treturn -1;\n\n\t\tif ( SlotCarrying( studio, material ) is var carrying \u0026\u0026 carrying \u003E= 0 )\n\t\t\treturn carrying;\n\n\t\tvar taken = new HashSet\u003Cint\u003E( FaceMaterialEdit.UsedSlots( studio ) );\n\n\t\tfor ( var slot = 1; slot \u003C= HighestSlot; slot\u002B\u002B )\n\t\t{\n\t\t\tif ( !taken.Contains( slot ) )\n\t\t\t\treturn slot;\n\t\t}\n\n\t\treturn -1;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The slot already carrying \u003Cparamref name=\u0022material\u0022/\u003E, or -1 if no slot does.\n\t///\n\t/// Rule 1 of \u003Csee cref=\u0022SlotFor\u0022/\u003E, on its own, because a browser asking \u0022does this part already\n\t/// use this material, and where\u0022 must not be answered with the free slot SlotFor would hand back\n\t/// \u2014 that would badge every material in the project with the same number and claim the document\n\t/// uses all of them.\n\t///\n\t/// The LOWEST such slot if a document somehow named two, purely so the answer is stable, and\n\t/// matched through \u003Csee cref=\u0022Normalise\u0022/\u003E so a slot named with backslashes still recognises the\n\t/// asset a picker hands over with forward ones.\n\t/// \u003C/summary\u003E\n\tpublic static int SlotCarrying( PartStudio studio, string material )\n\t{\n\t\tif ( studio is null )\n\t\t\treturn -1;\n\n\t\tvar wanted = Normalise( material );\n\n\t\tif ( wanted is null )\n\t\t\treturn -1;\n\n\t\tforeach ( var (slot, name) in studio.MaterialNames.OrderBy( kv =\u003E kv.Key ) )\n\t\t{\n\t\t\tif ( Normalise( name ) == wanted )\n\t\t\t\treturn slot;\n\t\t}\n\n\t\treturn -1;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Put \u003Cparamref name=\u0022material\u0022/\u003E on one face, and report whether anything changed.\n\t///\n\t/// The face is identified the way the right-click menu identifies it \u2014 the body and face index\n\t/// a raycast just returned, plus the FaceRef captured at the hit point, which is the half that\n\t/// survives a rebuild. \u003Cparamref name=\u0022slot\u0022/\u003E comes back so the caller can say which slot it\n\t/// landed on, because that number is the only thing on screen afterwards that explains where the\n\t/// material went; it is -1 when nothing was done.\n\t///\n\t/// Call Rebuild afterwards. Deliberately not done here, for the same reason FaceMaterialEdit\n\t/// does not: a caller dropping onto several faces should pay for one rebuild, not one each.\n\t/// \u003C/summary\u003E\n\tpublic static bool Drop( PartStudio studio, string bodyId, int faceIndex, FaceRef reference,\n\t\tstring material, out int slot ) =\u003E\n\t\tDrop( studio, bodyId, faceIndex, reference, material, out slot, out _ );\n\n\t/// \u003Csummary\u003E\n\t/// The same drop, also reporting the slot it retired \u2014 see \u003Csee cref=\u0022ReleaseVacatedSlot\u0022/\u003E \u2014\n\t/// or -1 when it retired none.\n\t///\n\t/// Worth saying out loud rather than doing quietly. The drop already has to announce the slot it\n\t/// chose, because nothing else on screen explains where the material went; a slot that stopped\n\t/// existing on the same gesture is the same kind of fact, and the Materials panel\u0027s count is\n\t/// about to change because of it.\n\t/// \u003C/summary\u003E\n\tpublic static bool Drop( PartStudio studio, string bodyId, int faceIndex, FaceRef reference,\n\t\tstring material, out int slot, out int released )\n\t{\n\t\tslot = -1;\n\t\treleased = -1;\n\n\t\tif ( studio is null )\n\t\t\treturn false;\n\n\t\tvar name = material?.Trim();\n\n\t\tif ( string.IsNullOrWhiteSpace( name ) )\n\t\t\treturn false;\n\n\t\tslot = SlotFor( studio, name );\n\n\t\tif ( slot \u003C 0 )\n\t\t\treturn false;\n\n\t\t// The NAME first, then the face. Both are edits and either can be the only one: dropping a\n\t\t// material the document has never seen names a fresh slot and moves the face onto it, while\n\t\t// dropping it onto a second face names nothing new and only moves the face.\n\t\t//\n\t\t// Compared through Normalise, not by string equality, so re-dropping the same asset spelled\n\t\t// with backslashes does not rewrite the name to the other spelling. The stored value would\n\t\t// still resolve to the same material, but the document would come back dirty, an undo step\n\t\t// would appear, and every open control would refresh \u2014 for a change nobody made.\n\t\tvar named = false;\n\n\t\tif ( !studio.MaterialNames.TryGetValue( slot, out var existing ) || Normalise( existing ) != Normalise( name ) )\n\t\t{\n\t\t\tstudio.MaterialNames[slot] = name;\n\t\t\tnamed = true;\n\t\t}\n\n\t\t// Whether the face is ALREADY on this slot, asked before Assign rather than inferred from\n\t\t// what it returns. Assign detaches before it attaches, so putting a face back where it\n\t\t// already was reports a change every time \u2014 true of the mechanism, wrong as an answer, and\n\t\t// the reason the right-click menu checks the same thing before calling it. Here it is not\n\t\t// an optimisation: dropping a material onto the face already wearing it is the ordinary way\n\t\t// to MISS by a few pixels, and reporting it as an edit puts a do-nothing step on the undo\n\t\t// stack that then has to be pressed through.\n\t\tvar previous = FaceSlot( studio, bodyId, faceIndex );\n\t\tvar moved = previous != slot\n\t\t\t\u0026\u0026 FaceMaterialEdit.Assign( studio, bodyId, faceIndex, reference, slot );\n\n\t\t// The face has left a slot behind. If it was the last thing holding that slot, the slot goes\n\t\t// with it \u2014 otherwise re-dropping onto one face walks it through a trail of named slots that\n\t\t// nothing wears and every exporter still writes.\n\t\tif ( moved \u0026\u0026 ReleaseVacatedSlot( studio, previous, slot ) )\n\t\t\treleased = previous;\n\n\t\treturn named || moved || released \u003E= 0;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Let go of the binding on the slot a drop just emptied, and say whether it did.\n\t///\n\t/// WHY A DROP HAS TO CLEAN UP AFTER ITSELF. Every other way of naming a slot names a slot you\n\t/// picked; a drop invents the number, so the numbers it invents are the ones nobody is watching.\n\t/// Changing your mind about one face five times walks it through five slots, and Detach does\n\t/// retire the four assignment features it emptied \u2014 but the four NAMES stay, and a name is what\n\t/// the exporters write. A box wearing three materials exports nine, and the first anyone hears\n\t/// of it is a material list in the engine that does not match the part.\n\t///\n\t/// NARROW ON PURPOSE. This retires ONE slot \u2014 the one this face just left \u2014 and only when\n\t/// nothing else is holding it:\n\t///\n\t/// - An assignment feature still targeting it means other faces are on it. A SUPPRESSED one\n\t///   counts as holding it too, because un-suppressing is one click away and the name has to\n\t///   still be there when it happens.\n\t/// - More than one face on it in the mesh means the slot did not come from an assignment at all\n\t///   \u2014 a feature that built geometry straight onto it \u2014 and those faces still wear it. The mesh\n\t///   read here is the one from BEFORE this edit, so the face being moved is still counted on its\n\t///   old slot: a count of one is that face alone, two is somebody else as well.\n\t///\n\t/// Slot 0 is never retired. It is the absence of an assignment rather than a binding this drop\n\t/// is entitled to clear, and a name on it is the part\u0027s base material that every untouched face\n\t/// is still wearing.\n\t///\n\t/// A slot named in the Materials panel and never painted is untouched by all of this, because no\n\t/// face ever left it. Reserving a slot now and filling it in later stays a thing you can do.\n\t/// \u003C/summary\u003E\n\tprivate static bool ReleaseVacatedSlot( PartStudio studio, int vacated, int landedOn )\n\t{\n\t\tif ( vacated \u003C= 0 || vacated == landedOn )\n\t\t\treturn false;\n\n\t\tif ( !studio.MaterialNames.ContainsKey( vacated ) )\n\t\t\treturn false;\n\n\t\tif ( studio.Features.OfType\u003CFaceMaterialFeature\u003E().Any( f =\u003E f.Material.Clamped == vacated ) )\n\t\t\treturn false;\n\n\t\tif ( FacesOn( studio, vacated ) \u003E 1 )\n\t\t\treturn false;\n\n\t\t// The SIZE goes with the name. A slot number that has been handed back is going to be handed\n\t\t// out again by SlotFor, and a scale left on it is inherited by whatever material lands there\n\t\t// next \u2014 brushed steel arriving at 48 units per tile because a floor tile used to be on slot\n\t\t// 3. The scale is only meaningful alongside the binding it was chosen for.\n\t\tMaterialScale.SetScale( studio, vacated, MaterialScale.Unscaled );\n\n\t\treturn studio.MaterialNames.Remove( vacated );\n\t}\n\n\t/// \u003Csummary\u003EHow many faces sit on a slot, across every body, in the mesh as it currently\n\t/// stands.\u003C/summary\u003E\n\tprivate static int FacesOn( PartStudio studio, int slot )\n\t{\n\t\tvar count = 0;\n\n\t\tforeach ( var body in studio.Bodies ?? Enumerable.Empty\u003CBody\u003E() )\n\t\t{\n\t\t\tif ( body?.Mesh is not { } mesh )\n\t\t\t\tcontinue;\n\n\t\t\tcount \u002B= mesh.Faces.Count( f =\u003E f.Material == slot );\n\t\t}\n\n\t\treturn count;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The slot a face is on right now, or -1 if the body or face cannot be found.\n\t///\n\t/// Read off the BUILT mesh rather than worked out from the assignments in the tree, because the\n\t/// mesh is where they have all already been applied in order \u2014 including a later assignment\n\t/// overriding an earlier one on the same face, which reading the features would have to redo.\n\t/// \u003C/summary\u003E\n\tprivate static int FaceSlot( PartStudio studio, string bodyId, int faceIndex )\n\t{\n\t\tvar body = studio?.Bodies?.FirstOrDefault( b =\u003E b?.Id == bodyId );\n\n\t\tif ( body?.Mesh is not { } mesh || faceIndex \u003C 0 || faceIndex \u003E= mesh.Faces.Count )\n\t\t\treturn -1;\n\n\t\treturn mesh.Faces[faceIndex].Material;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A material path reduced to something two spellings of the same asset agree on.\n\t///\n\t/// Separators and case, because a path typed by hand, one from an asset picker and one from a\n\t/// drag can differ in both while naming one file, and a document that disagrees with itself\n\t/// about that grows a second slot for a material it already has.\n\t///\n\t/// Public because the Materials dock has to key a lookup of every material in the project by the\n\t/// same rule this file matches slots with. It could have asked \u003Csee cref=\u0022SlotCarrying\u0022/\u003E once\n\t/// per material instead, and that is a scan of the whole project against the whole slot table on\n\t/// every rebuild \u2014 which includes every tick of a dragged parameter. Exporting the rule lets it\n\t/// build the index once and walk the handful of named slots instead. What must not happen is a\n\t/// second copy of the rule over there: the two would agree until one of them learned about\n\t/// trailing slashes.\n\t/// \u003C/summary\u003E\n\tpublic static string Normalise( string path ) =\u003E\n\t\tstring.IsNullOrWhiteSpace( path ) ? null : path.Trim().Replace( \u0027\\\\\u0027, \u0027/\u0027 ).ToLowerInvariant();\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Sketch/SketchSolver.cs","FileName":"SketchSolver.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003EWhat a solve did, and what it found out about the sketch on the way.\u003C/summary\u003E\npublic sealed class SolveResult\n{\n\t/// \u003Csummary\u003EEvery constraint is satisfied to tolerance.\u003C/summary\u003E\n\tpublic bool Converged;\n\n\tpublic int Iterations;\n\n\t/// \u003Csummary\u003ENorm of the residual vector at the final state. Zero is a satisfied sketch.\u003C/summary\u003E\n\tpublic double Residual;\n\n\t/// \u003Csummary\u003E\n\t/// Degrees of freedom left: how many independent ways the sketch can still be moved without\n\t/// breaking a constraint. Onshape\u0027s \u0022under defined\u0022 is this being greater than zero.\n\t///\n\t/// Counted as free variables minus the RANK of the Jacobian, not minus the number of\n\t/// constraints \u2014 the whole point is that two constraints saying the same thing only remove one\n\t/// freedom, and counting rows would claim otherwise.\n\t///\n\t/// Read it knowing what the pin leaves behind: pinning one point kills translation but not\n\t/// rotation, so a rectangle with all four sides dimensioned still reports 1 until something\n\t/// (a horizontal constraint, usually) fixes its orientation.\n\t/// \u003C/summary\u003E\n\tpublic int DegreesOfFreedom;\n\n\t/// \u003Csummary\u003EConstraint rows beyond the rank \u2014 rules that repeat something already implied.\n\t/// Harmless when consistent, and the reason a solve can be redundant and still converge; the\n\t/// diagnosis a user wants when adding one more dimension does nothing.\u003C/summary\u003E\n\tpublic int RedundantConstraints;\n}\n\n/// \u003Csummary\u003E\n/// The sketch constraint solver: Levenberg-Marquardt over the constraint residuals.\n///\n/// The shape of the problem. Every point is two unknowns, every constraint is one or more equations\n/// that should read zero, and the answer is the point positions that make them all zero. That is a\n/// nonlinear least squares problem, and LM is the standard way to take it: a Gauss-Newton step\n/// where it behaves, damped toward gradient descent where it does not, with the damping (\u03BB) raised\n/// on a step that made things worse and lowered on one that helped.\n///\n/// WHY A POINT IS PINNED. The equations only ever mention differences between points, so the whole\n/// sketch can slide anywhere without changing a single residual. J\u1D40J is singular in that direction\n/// and the step is not unique. Pinning one point removes the slide and leaves the rest free. It\n/// does not remove rotation, which is why SolveResult.DegreesOfFreedom bottoms out at 1 for an\n/// otherwise fully dimensioned sketch \u2014 the honest answer, since such a sketch really can be spun.\n/// The editor should pin whichever point the user is dragging, so the shape resolves around their\n/// hand rather than around point 0.\n///\n/// WHY IT SOLVES IN DOUBLE AND STORES IN FLOAT. Sketch points are float, and J\u1D40J squares the\n/// condition number of J \u2014 a right angle between near-parallel lines loses far more digits than\n/// float has to give. The solve runs in double and the answer is narrowed on the way out, which is\n/// why the convergence floor here is 1e-4 rather than the tolerance: past that, the residual is\n/// measuring the storage type, not the sketch.\n/// \u003C/summary\u003E\npublic static class SketchSolver\n{\n\tconst double Tolerance = 1e-6;\n\tconst int MaxIterations = 40;\n\tconst double LambdaInit = 1e-3;\n\tconst double LambdaMax = 1e12;\n\n\t/// \u003Csummary\u003EThe residual below which a non-converged solve is still called solved. Float\n\t/// coordinates cannot express better, so demanding Tolerance of them would report failure on a\n\t/// sketch that is as correct as its storage allows.\u003C/summary\u003E\n\tconst double FloatFloor = 1e-4;\n\n\t/// \u003Csummary\u003E\n\t/// Move the sketch\u0027s points to satisfy its constraints, in place.\n\t///\n\t/// A sketch with no constraints is a no-op and reports converged \u2014 every sketch drawn before the\n\t/// solver existed goes down that path, which is what makes this safe to call unconditionally\n\t/// from the rebuild.\n\t/// \u003C/summary\u003E\n\t/// \u003Cparam name=\u0022pinnedPoint\u0022\u003EThe point held fixed to give the sketch an absolute frame. Pass\n\t/// the point being dragged, when there is one.\u003C/param\u003E\n\tpublic static SolveResult Solve( Sketch sketch, int pinnedPoint = 0 )\n\t{\n\t\tvar result = new SolveResult { Converged = true };\n\n\t\tif ( sketch is null || sketch.Points.Count == 0 )\n\t\t\treturn result;\n\n\t\tvar constraints = new List\u003CIConstraint\u003E( sketch.Constraints.Count );\n\n\t\tforeach ( var stored in sketch.Constraints )\n\t\t{\n\t\t\tif ( stored.Build( sketch ) is { } c )\n\t\t\t\tconstraints.Add( c );\n\t\t}\n\n\t\t// IMPLICIT, AND NOT OPTIONAL. An arc is a centre and two endpoints, and its radius is read\n\t\t// off the centre-to-START distance \u2014 Tessellate then snaps its last sample onto End wherever\n\t\t// End happens to be. Nothing has ever required the two endpoints to be the same distance from\n\t\t// the centre, and while coordinates were only ever typed, nothing moved them apart.\n\t\t//\n\t\t// A solver moves points. Constrain anything touching one end of an arc and the other end\n\t\t// drifts off its own circle, and what comes back is not a bad arc that complains \u2014 it is an\n\t\t// arc drawn at the wrong radius with a kink in the last segment, which looks like a rendering\n\t\t// glitch and is nothing of the kind.\n\t\t//\n\t\t// So every arc contributes \u0022both endpoints are equidistant from my centre\u0022 whether the user\n\t\t// asked for it or not. It is not design intent, it is what an arc IS, and a user who never\n\t\t// adds a constraint never pays for it because the whole solve is skipped below.\n\t\tforeach ( var curve in sketch.Curves.OfType\u003CSketchArc\u003E() )\n\t\t{\n\t\t\tif ( curve.Center != curve.Start \u0026\u0026 curve.Center != curve.End )\n\t\t\t\tconstraints.Add( new EqualLengthConstraint( curve.Center, curve.Start, curve.Center, curve.End ) );\n\t\t}\n\n\t\t// Only the STORED constraints decide whether there is anything to do. A sketch with arcs and\n\t\t// no constraints is one nobody has asked anything of, and solving it would move points that\n\t\t// were placed deliberately.\n\t\tif ( sketch.Constraints.Count == 0 || constraints.Count == 0 )\n\t\t\treturn result;\n\n\t\tvar nPts = sketch.Points.Count;\n\n\t\t// Column map: each point\u0027s slot among the free variables, or -1 for the pinned one. Doing it\n\t\t// as a map rather than \u0022index minus one\u0022 is what lets any point be the pin.\n\t\tvar column = new int[nPts];\n\t\tvar free = 0;\n\n\t\tfor ( var i = 0; i \u003C nPts; i\u002B\u002B )\n\t\t\tcolumn[i] = i == pinnedPoint ? -1 : free\u002B\u002B;\n\n\t\tvar n = free * 2;\n\n\t\tif ( n == 0 )\n\t\t\treturn result;\n\n\t\tvar points = new Vec2[nPts];\n\n\t\tfor ( var i = 0; i \u003C nPts; i\u002B\u002B )\n\t\t\tpoints[i] = sketch.Points[i];\n\n\t\tvar rows = 0;\n\t\tvar widest = 1;\n\n\t\tforeach ( var c in constraints )\n\t\t{\n\t\t\trows \u002B= c.ResidualCount;\n\t\t\twidest = Math.Max( widest, c.ResidualCount );\n\t\t}\n\n\t\tvar residual = new double[rows];\n\t\tvar rowBuf = new ConstraintResult[widest];\n\n\t\tvar J = new double[rows * n];\n\t\tvar g = new double[n];\n\t\tvar H = new double[n * n];\n\t\tvar dx = new double[n];\n\n\t\tvar lambda = LambdaInit;\n\n\t\tfor ( var iter = 0; iter \u003C MaxIterations; iter\u002B\u002B )\n\t\t{\n\t\t\tresult.Iterations = iter \u002B 1;\n\n\t\t\tArray.Clear( residual, 0, residual.Length );\n\t\t\tArray.Clear( J, 0, J.Length );\n\n\t\t\tvar row = 0;\n\n\t\t\tforeach ( var c in constraints )\n\t\t\t{\n\t\t\t\tvar needed = c.ResidualCount;\n\t\t\t\tc.Evaluate( points, rowBuf.AsSpan( 0, needed ) );\n\n\t\t\t\tfor ( var r = 0; r \u003C needed; r\u002B\u002B )\n\t\t\t\t{\n\t\t\t\t\tresidual[row] = rowBuf[r].Residual;\n\n\t\t\t\t\tforeach ( var (point, gx, gy) in rowBuf[r].Jacobian )\n\t\t\t\t\t{\n\t\t\t\t\t\tif ( point \u003C 0 || point \u003E= nPts || column[point] \u003C 0 )\n\t\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\t\tvar col = column[point] * 2;\n\t\t\t\t\t\tJ[row * n \u002B col] \u002B= gx;\n\t\t\t\t\t\tJ[row * n \u002B col \u002B 1] \u002B= gy;\n\t\t\t\t\t}\n\n\t\t\t\t\trow\u002B\u002B;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tvar residualSq = 0.0;\n\n\t\t\tfor ( var i = 0; i \u003C rows; i\u002B\u002B )\n\t\t\t\tresidualSq \u002B= residual[i] * residual[i];\n\n\t\t\tresult.Residual = Math.Sqrt( residualSq );\n\n\t\t\tif ( result.Residual \u003C Tolerance )\n\t\t\t{\n\t\t\t\tFinish( sketch, points, J, rows, n, result, converged: true );\n\t\t\t\treturn result;\n\t\t\t}\n\n\t\t\t// g = J\u1D40r\n\t\t\tArray.Clear( g, 0, n );\n\n\t\t\tfor ( var i = 0; i \u003C rows; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar ri = residual[i];\n\n\t\t\t\tfor ( var j = 0; j \u003C n; j\u002B\u002B )\n\t\t\t\t\tg[j] \u002B= J[i * n \u002B j] * ri;\n\t\t\t}\n\n\t\t\t// H = J\u1D40J \u002B \u03BBI. Cholesky needs it positive definite, and the \u03BB on the diagonal is\n\t\t\t// exactly what guarantees that however rank-deficient J\u1D40J is.\n\t\t\tArray.Clear( H, 0, H.Length );\n\n\t\t\tfor ( var i = 0; i \u003C rows; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tfor ( var j = 0; j \u003C n; j\u002B\u002B )\n\t\t\t\t{\n\t\t\t\t\tvar jij = J[i * n \u002B j];\n\n\t\t\t\t\tif ( jij == 0 )\n\t\t\t\t\t\tcontinue;\n\n\t\t\t\t\tfor ( var k = 0; k \u003C n; k\u002B\u002B )\n\t\t\t\t\t\tH[j * n \u002B k] \u002B= jij * J[i * n \u002B k];\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tfor ( var j = 0; j \u003C n; j\u002B\u002B )\n\t\t\t\tH[j * n \u002B j] \u002B= lambda;\n\n\t\t\t// CholeskySolve overwrites H with its factorization, so J is the only thing left holding\n\t\t\t// the Jacobian by the time the analysis wants it. That is why the analysis reads J.\n\t\t\tif ( !CholeskySolve( H, n, g, dx ) )\n\t\t\t{\n\t\t\t\tlambda = Math.Min( lambda * 10, LambdaMax );\n\n\t\t\t\tif ( lambda \u003E= LambdaMax )\n\t\t\t\t{\n\t\t\t\t\tFinish( sketch, points, J, rows, n, result, result.Residual \u003C FloatFloor );\n\t\t\t\t\treturn result;\n\t\t\t\t}\n\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar saved = (Vec2[])points.Clone();\n\n\t\t\tfor ( var i = 0; i \u003C nPts; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tif ( column[i] \u003C 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tvar col = column[i] * 2;\n\t\t\t\tpoints[i] = new Vec2( (float)(points[i].x - dx[col]), (float)(points[i].y - dx[col \u002B 1]) );\n\t\t\t}\n\n\t\t\t// Did the step help? Measured against THIS iteration\u0027s residual, not the last accepted\n\t\t\t// one. Those differ only on the first pass \u2014 where the last-accepted value is infinity\n\t\t\t// and every step, including a disastrous one, would be taken.\n\t\t\tvar steppedSq = 0.0;\n\n\t\t\tforeach ( var c in constraints )\n\t\t\t{\n\t\t\t\tvar needed = c.ResidualCount;\n\t\t\t\tc.Evaluate( points, rowBuf.AsSpan( 0, needed ) );\n\n\t\t\t\tfor ( var r = 0; r \u003C needed; r\u002B\u002B )\n\t\t\t\t\tsteppedSq \u002B= rowBuf[r].Residual * rowBuf[r].Residual;\n\t\t\t}\n\n\t\t\tif ( steppedSq \u003C residualSq )\n\t\t\t{\n\t\t\t\tlambda = Math.Max( lambda * 0.25, 1e-12 );\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tpoints = saved;\n\t\t\tlambda = Math.Min( lambda * 4, LambdaMax );\n\n\t\t\tif ( lambda \u003E= LambdaMax )\n\t\t\t{\n\t\t\t\tFinish( sketch, points, J, rows, n, result, result.Residual \u003C FloatFloor );\n\t\t\t\treturn result;\n\t\t\t}\n\t\t}\n\n\t\tFinish( sketch, points, J, rows, n, result, result.Residual \u003C FloatFloor );\n\t\treturn result;\n\t}\n\n\t/// \u003Csummary\u003EWrite the solved positions back and fill in the diagnosis.\u003C/summary\u003E\n\tstatic void Finish( Sketch sketch, Vec2[] points, double[] J, int rows, int n, SolveResult result, bool converged )\n\t{\n\t\tfor ( var i = 0; i \u003C points.Length; i\u002B\u002B )\n\t\t\tsketch.Points[i] = points[i];\n\n\t\tresult.Converged = converged;\n\n\t\tvar rank = Rank( J, rows, n );\n\t\tresult.DegreesOfFreedom = n - rank;\n\t\tresult.RedundantConstraints = rows - rank;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Rank of the Jacobian, by Gaussian elimination with partial pivoting on a copy.\n\t///\n\t/// This is what separates \u0022under defined by two\u0022 from \u0022you added four constraints that between\n\t/// them say three things\u0022. Counting constraint rows cannot tell those apart; counting pivots\n\t/// can. The threshold is relative to the largest entry, because J\u0027s entries carry the scale of\n\t/// the sketch and an absolute epsilon would call a large sketch full-rank and a small one\n\t/// singular.\n\t/// \u003C/summary\u003E\n\tstatic int Rank( double[] J, int rows, int n )\n\t{\n\t\tif ( rows == 0 || n == 0 )\n\t\t\treturn 0;\n\n\t\tvar m = (double[])J.Clone();\n\t\tvar largest = 0.0;\n\n\t\tforeach ( var v in m )\n\t\t\tlargest = Math.Max( largest, Math.Abs( v ) );\n\n\t\tif ( largest == 0.0 )\n\t\t\treturn 0;\n\n\t\tvar epsilon = largest * 1e-9;\n\t\tvar rank = 0;\n\n\t\tfor ( var col = 0; col \u003C n \u0026\u0026 rank \u003C rows; col\u002B\u002B )\n\t\t{\n\t\t\tvar pivot = -1;\n\t\t\tvar best = epsilon;\n\n\t\t\tfor ( var r = rank; r \u003C rows; r\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar v = Math.Abs( m[r * n \u002B col] );\n\n\t\t\t\tif ( v \u003E best )\n\t\t\t\t{\n\t\t\t\t\tbest = v;\n\t\t\t\t\tpivot = r;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( pivot \u003C 0 )\n\t\t\t\tcontinue;\n\n\t\t\tif ( pivot != rank )\n\t\t\t{\n\t\t\t\tfor ( var c = 0; c \u003C n; c\u002B\u002B )\n\t\t\t\t\t(m[rank * n \u002B c], m[pivot * n \u002B c]) = (m[pivot * n \u002B c], m[rank * n \u002B c]);\n\t\t\t}\n\n\t\t\tvar inv = 1.0 / m[rank * n \u002B col];\n\n\t\t\tfor ( var r = rank \u002B 1; r \u003C rows; r\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar factor = m[r * n \u002B col] * inv;\n\n\t\t\t\tif ( factor == 0 )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tfor ( var c = col; c \u003C n; c\u002B\u002B )\n\t\t\t\t\tm[r * n \u002B c] -= factor * m[rank * n \u002B c];\n\t\t\t}\n\n\t\t\trank\u002B\u002B;\n\t\t}\n\n\t\treturn rank;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// In-place Cholesky factorization of symmetric positive-definite H (n\u00D7n, row-major), then\n\t/// forward and back substitution to solve H x = b.\n\t///\n\t/// False when a pivot comes out non-positive, which means H is not positive definite after all \u2014\n\t/// \u03BB is still too small for how singular J\u1D40J is. The caller\u0027s answer to that is to raise \u03BB and\n\t/// try again, which is LM working as intended rather than an error.\n\t/// \u003C/summary\u003E\n\tstatic bool CholeskySolve( double[] H, int n, double[] b, double[] x )\n\t{\n\t\tfor ( var i = 0; i \u003C n; i\u002B\u002B )\n\t\t{\n\t\t\tfor ( var j = 0; j \u003C= i; j\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar sum = H[i * n \u002B j];\n\n\t\t\t\tfor ( var k = 0; k \u003C j; k\u002B\u002B )\n\t\t\t\t\tsum -= H[i * n \u002B k] * H[j * n \u002B k];\n\n\t\t\t\tif ( i == j )\n\t\t\t\t{\n\t\t\t\t\tif ( sum \u003C= 1e-18 )\n\t\t\t\t\t\treturn false;\n\n\t\t\t\t\tH[i * n \u002B j] = Math.Sqrt( sum );\n\t\t\t\t}\n\t\t\t\telse\n\t\t\t\t{\n\t\t\t\t\tH[i * n \u002B j] = sum / H[j * n \u002B j];\n\t\t\t\t}\n\t\t\t}\n\t\t}\n\n\t\t// L y = b\n\t\tfor ( var i = 0; i \u003C n; i\u002B\u002B )\n\t\t{\n\t\t\tvar sum = b[i];\n\n\t\t\tfor ( var k = 0; k \u003C i; k\u002B\u002B )\n\t\t\t\tsum -= H[i * n \u002B k] * x[k];\n\n\t\t\tx[i] = sum / H[i * n \u002B i];\n\t\t}\n\n\t\t// L\u1D40 x = y\n\t\tfor ( var i = n - 1; i \u003E= 0; i-- )\n\t\t{\n\t\t\tvar sum = x[i];\n\n\t\t\tfor ( var k = i \u002B 1; k \u003C n; k\u002B\u002B )\n\t\t\t\tsum -= H[k * n \u002B i] * x[k];\n\n\t\t\tx[i] = sum / H[i * n \u002B i];\n\t\t}\n\n\t\treturn true;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Editor/EffigyEditor/EffigySketchGridBar.cs","FileName":"EffigySketchGridBar.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using Editor;\nusing System;\n\nnamespace Marionette.EditorTools;\n\n/// \u003Csummary\u003E\n/// The grid switch and its spacing, floating in the corner of the viewport while a sketch is open.\n///\n/// WHY IT IS NOT ONLY IN SETTINGS. Both controls already live in Edit \u0026gt; Settings, and that is the\n/// right home for them when you are setting up how the tool behaves. It is the wrong home for them\n/// while you are drawing: the grid is the paper, changing paper is part of drawing, and a dialog\n/// three menus away is not somewhere you go mid-sketch. Every sketcher worth using puts these two\n/// on the canvas.\n///\n/// NOT A SECOND COPY OF THE VALUE. This reads and writes the viewport\u0027s own properties - the same\n/// two the settings window sets - and re-reads them every time it is shown, so the two controls can\n/// never disagree about what is true. The spacing list itself is EffigySettingsWindow\u0027s, for the\n/// same reason: two lists drift, and a value that exists on one dropdown and not the other reads as\n/// the setting having been lost.\n///\n/// AT THE END OF THE TOOL ROW, not floating on the model. It belongs to the mode you are in, the\n/// way the Line and Rectangle buttons beside it do, and chrome is where a mode\u0027s controls go - a\n/// panel sitting on the part is one more thing between you and the thing you are drawing. Flush\n/// right, so the tools growing and shrinking as you change stage never move it.\n/// \u003C/summary\u003E\ninternal sealed class EffigySketchGridBar : Widget\n{\n\t/// \u003Csummary\u003EThe tool buttons\u0027 own height, so the two read as one row of controls rather than\n\t/// as a panel parked next to them.\u003C/summary\u003E\n\tpublic const float BarHeight = EffigyToolChrome.ButtonHeight;\n\tpublic const float BarWidth = 210f;\n\n\tprivate readonly Button _toggle;\n\tprivate readonly ComboBox _spacing;\n\n\t/// \u003Csummary\u003ESet while Refresh writes the controls. Both of them fire their change callbacks on\n\t/// assignment exactly as a click does, and without this a refresh would turn straight round and\n\t/// write the value it had only just read - harmless for the toggle, and for the dropdown a way\n\t/// to reset the spacing to whatever happened to be first.\u003C/summary\u003E\n\tprivate bool _syncing;\n\n\t/// \u003Csummary\u003ERaised after either control has changed the viewport, so the window can save the\n\t/// setting the same way the settings dialog\u0027s own callback does. The viewport is already\n\t/// updated by the time this fires.\u003C/summary\u003E\n\tpublic Action Changed { get; set; }\n\n\tprivate readonly EffigyViewport _viewport;\n\n\tpublic EffigySketchGridBar( Widget parent, EffigyViewport viewport ) : base( parent )\n\t{\n\t\t_viewport = viewport;\n\n\t\t// The same two flags every floating widget in this tool sets \u2014 a plain Widget paints the\n\t\t// system background, which is a white slab on the 3D view.\n\t\tTranslucentBackground = true;\n\t\tNoSystemBackground = true;\n\n\t\tVisible = false;\n\t\tFixedHeight = BarHeight;\n\t\tFixedWidth = BarWidth;\n\n\t\tLayout = Layout.Row();\n\t\tLayout.Spacing = 6;\n\t\tLayout.Margin = new Sandbox.UI.Margin( 6, 2, 6, 2 );\n\n\t\t_toggle = new Button( \u0022Grid\u0022, \u0022grid_on\u0022, this )\n\t\t{\n\t\t\tToolTip = \u0022Draw the grid on the face you are sketching on. The lines are the intervals \u0022\n\t\t\t\t\u002B \u0022the cursor snaps to.\u0022,\n\t\t\tFixedWidth = 74f,\n\t\t};\n\n\t\t_toggle.Clicked = ToggleGrid;\n\n\t\tLayout.Add( _toggle );\n\n\t\t_spacing = new ComboBox( this )\n\t\t{\n\t\t\tToolTip = \u0022How far apart the grid lines sit, in sketch units. Automatic fits the step \u0022\n\t\t\t\t\u002B \u0022to the face you are on and to how close the camera is.\u0022,\n\t\t};\n\n\t\tforeach ( var value in EffigySettingsWindow.Spacings )\n\t\t{\n\t\t\tvar step = value;\n\n\t\t\t_spacing.AddItem( EffigySettingsWindow.Describe( step ), onSelected: () =\u003E SetSpacing( step ) );\n\t\t}\n\n\t\tLayout.Add( _spacing, 1 );\n\n\t\tRefresh();\n\t}\n\n\t/// \u003Csummary\u003EThe tool row\u0027s background, so the gaps between the two controls disappear into the\n\t/// bar rather than showing as a panel laid on it. A widget cannot simply decline to paint: a\n\t/// rect it leaves alone keeps whatever the previous frame put there.\u003C/summary\u003E\n\tpublic Color GapColor { get; set; } = Theme.ControlBackground;\n\n\tprotected override void OnPaint()\n\t{\n\t\tPaint.ClearPen();\n\t\tPaint.SetBrush( GapColor );\n\t\tPaint.DrawRect( LocalRect );\n\t}\n\n\t/// \u003Csummary\u003EPoint both controls at what the viewport actually holds. Called on every show, and\n\t/// again whenever the settings window has been the one to change it.\u003C/summary\u003E\n\tpublic void Refresh()\n\t{\n\t\tif ( !_viewport.IsValid )\n\t\t\treturn;\n\n\t\t_syncing = true;\n\n\t\ttry\n\t\t{\n\t\t\tvar on = _viewport.ShowPlaneGrid;\n\n\t\t\t_toggle.Icon = on ? \u0022grid_on\u0022 : \u0022grid_off\u0022;\n\n\t\t\t// Tinted rather than labelled on and off. The button says \u0022Grid\u0022 either way, so the\n\t\t\t// only thing that has to change is whether it reads as engaged - and a label that\n\t\t\t// alternates between two words is a control you have to read to use.\n\t\t\t_toggle.Tint = on ? Theme.Blue : Theme.TextControl.WithAlpha( 0.5f );\n\n\t\t\tvar spacing = _viewport.GridSpacing;\n\t\t\tvar index = 0;\n\n\t\t\tfor ( var i = 0; i \u003C EffigySettingsWindow.Spacings.Length; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tif ( MathF.Abs( EffigySettingsWindow.Spacings[i] - spacing ) \u003C 0.0001f )\n\t\t\t\t{\n\t\t\t\t\tindex = i;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\t_spacing.CurrentIndex = index;\n\t\t}\n\t\tfinally\n\t\t{\n\t\t\t_syncing = false;\n\t\t}\n\t}\n\n\tprivate void ToggleGrid()\n\t{\n\t\tif ( _syncing || !_viewport.IsValid )\n\t\t\treturn;\n\n\t\t_viewport.ShowPlaneGrid = !_viewport.ShowPlaneGrid;\n\n\t\tRefresh();\n\t\tChanged?.Invoke();\n\t}\n\n\tprivate void SetSpacing( float step )\n\t{\n\t\tif ( _syncing || !_viewport.IsValid )\n\t\t\treturn;\n\n\t\t_viewport.GridSpacing = step;\n\n\t\t// Choosing a spacing while the grid is off is asking to see it. Turning it on is what was\n\t\t// meant, and leaving it off would make the dropdown look broken.\n\t\tif ( !_viewport.ShowPlaneGrid )\n\t\t\t_viewport.ShowPlaneGrid = true;\n\n\t\tRefresh();\n\t\tChanged?.Invoke();\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Features/FaceMaterialEdit.cs","FileName":"FaceMaterialEdit.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// Putting ONE face on a material slot.\n///\n/// FaceMaterialFeature is built for the other case \u2014 a set of faces chosen deliberately in a dialog\n/// \u2014 and the editor\u0027s right-click menu wants the small one: this face, this slot, now. The two\n/// differ in bookkeeping rather than in effect, and the bookkeeping is the part worth having in the\n/// kernel where it can be tested: which existing assignment to reuse, what to do with the one the\n/// face is leaving, and where a new one goes in a tree that may be rolled back.\n///\n/// It edits the HISTORY, never the mesh. Writing the slot straight onto Body.Mesh would hold until\n/// the next rebuild and then silently revert, because bodies are remade from scratch every time.\n/// \u003C/summary\u003E\npublic static class FaceMaterialEdit\n{\n\t/// \u003Csummary\u003E\n\t/// Move one face onto \u003Cparamref name=\u0022slot\u0022/\u003E, and report whether anything changed.\n\t///\n\t/// The face is identified by the body and face index it resolved to a moment ago \u2014 the caller\n\t/// has just raycast it \u2014 and stored as the FaceRef it captured, which is what survives the\n\t/// rebuild. Both are needed: the index says which face to take OUT of the assignments that\n\t/// currently hold it, the reference is what goes IN.\n\t///\n\t/// Call Rebuild afterwards. This deliberately does not, so a caller changing several faces pays\n\t/// for one rebuild rather than one each.\n\t/// \u003C/summary\u003E\n\tpublic static bool Assign( PartStudio studio, string bodyId, int faceIndex, FaceRef reference, int slot )\n\t{\n\t\tif ( studio is null || string.IsNullOrEmpty( bodyId ) || faceIndex \u003C 0 )\n\t\t\treturn false;\n\n\t\tif ( slot \u003C 0 )\n\t\t\treturn false;\n\n\t\tvar changed = Detach( studio, bodyId, faceIndex );\n\n\t\t// Slot 0 is the ABSENCE of an assignment rather than an assignment to zero \u2014 it is what every\n\t\t// face starts on, and what the viewport pointedly does not tint. Detaching has already done\n\t\t// the whole job.\n\t\tif ( slot == 0 )\n\t\t\treturn changed;\n\n\t\tvar target = SlotFeature( studio, slot );\n\n\t\tif ( target is null )\n\t\t{\n\t\t\ttarget = new FaceMaterialFeature();\n\t\t\ttarget.Material.Value = slot;\n\n\t\t\t// AT THE ROLLBACK BAR, not at the end. Below the bar a feature is not evaluated, so the\n\t\t\t// face would sit there unpainted with nothing on screen to explain why.\n\t\t\t//\n\t\t\t// AND THE BAR SITTING AT EXACTLY Features.Count COUNTS AS \u0022at the bar\u0022. A \u0060\u003C\u0060 test here\n\t\t\t// sends that case to Add, which appends the new assignment ONTO the bar rather than\n\t\t\t// above it, and EffectiveCount then leaves it out - the face stays unpainted, which is\n\t\t\t// the precise failure this insert exists to prevent. The editor had the same\n\t\t\t// comparison in the same shape and it cost a sketch its plane.\n\t\t\tvar at = Math.Min( studio.RollbackIndex, studio.Features.Count );\n\n\t\t\tif ( at \u003C studio.Features.Count )\n\t\t\t\tstudio.Insert( at, target );\n\t\t\telse\n\t\t\t\tstudio.Add( target );\n\n\t\t\t// int.MaxValue already means \u0022evaluate everything\u0022 and has to stay that way.\n\t\t\tif ( studio.RollbackIndex \u003C studio.Features.Count )\n\t\t\t\tstudio.RollbackIndex = at \u002B 1;\n\t\t}\n\n\t\ttarget.Faces.Add( reference );\n\t\tstudio.MarkDirty( target );\n\n\t\treturn true;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Take a face out of every assignment currently holding it, and drop any assignment that just\n\t/// lost its last face.\n\t///\n\t/// Relying on tree order instead \u2014 the later feature wins, so why bother \u2014 works today and rots:\n\t/// right-clicking the same face four times would leave four assignments to it, three of them\n\t/// invisible on screen and all four written to the file.\n\t/// \u003C/summary\u003E\n\tpublic static bool Detach( PartStudio studio, string bodyId, int faceIndex )\n\t{\n\t\tif ( studio is null || string.IsNullOrEmpty( bodyId ) || faceIndex \u003C 0 )\n\t\t\treturn false;\n\n\t\tvar changed = false;\n\t\tvar emptied = new List\u003CFaceMaterialFeature\u003E();\n\n\t\t// MATCHED ACROSS THE WHOLE SURFACE, because that is what gets painted. An assignment made\n\t\t// by clicking one fragment of a wall resolves to whichever fragment it captured, and a\n\t\t// later click landing on a different fragment of the same wall is the same face to the\n\t\t// person doing it - matching on the index alone left the old assignment in the tree,\n\t\t// invisible on screen and still written to the file.\n\t\tvar surface = FaceSurfaceOf( studio, bodyId, faceIndex );\n\n\t\tforeach ( var feature in studio.Features.OfType\u003CFaceMaterialFeature\u003E().ToList() )\n\t\t{\n\t\t\tvar removed = false;\n\n\t\t\t// Matched by RESOLVED FACE, not by comparing the stored references. Two captures of the\n\t\t\t// same face record different hit points and are not equal to one another, which is the\n\t\t\t// same trap the dialog\u0027s selection box documents.\n\t\t\tfor ( var i = feature.Faces.Count - 1; i \u003E= 0; i-- )\n\t\t\t{\n\t\t\t\tif ( !FacePlane.TryResolveFace( studio.Bodies, feature.Faces[i], out var body, out var index ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tif ( body.Id != bodyId )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tif ( index != faceIndex \u0026\u0026 !(surface?.Contains( index ) ?? false) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\tfeature.Faces.RemoveAt( i );\n\t\t\t\tremoved = true;\n\t\t\t}\n\n\t\t\tif ( !removed )\n\t\t\t\tcontinue;\n\n\t\t\tchanged = true;\n\t\t\tstudio.MarkDirty( feature );\n\n\t\t\tif ( feature.Faces.Count == 0 )\n\t\t\t\temptied.Add( feature );\n\t\t}\n\n\t\t// An assignment with no faces left FAILS the moment it runs, which is right when the faces\n\t\t// went missing under it and wrong here: it only emptied because this edit took its last face,\n\t\t// and leaving a red feature in the tree for that would be baffling.\n\t\tforeach ( var feature in emptied )\n\t\t\tstudio.Remove( feature );\n\n\t\treturn changed;\n\t}\n\n\t/// \u003Csummary\u003EThe surface a body\u0027s face belongs to, or null when the body is not in the studio.\n\t/// Null rather than an empty surface so a caller can tell \u0022nothing to widen to\u0022 from \u0022widened\n\t/// to nothing\u0022.\u003C/summary\u003E\n\tstatic FaceSurface FaceSurfaceOf( PartStudio studio, string bodyId, int faceIndex )\n\t{\n\t\tforeach ( var body in studio.Bodies )\n\t\t{\n\t\t\tif ( body?.Mesh is { } mesh \u0026\u0026 body.Id == bodyId )\n\t\t\t\treturn FaceSurface.FromFace( mesh, faceIndex );\n\t\t}\n\n\t\treturn null;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The live assignment for a slot above the rollback bar, or null.\n\t///\n\t/// The LAST one rather than the first, because that is the one that would win anyway \u2014 the tree\n\t/// runs in order and a later assignment overrides an earlier one on any face they share.\n\t/// Reusing it is what keeps a session of clicking faces from growing a feature per click.\n\t/// \u003C/summary\u003E\n\tpublic static FaceMaterialFeature SlotFeature( PartStudio studio, int slot )\n\t{\n\t\tFaceMaterialFeature found = null;\n\n\t\tvar limit = Math.Min( studio.EffectiveCount, studio.Features.Count );\n\n\t\tfor ( var i = 0; i \u003C limit; i\u002B\u002B )\n\t\t{\n\t\t\tif ( studio.Features[i] is FaceMaterialFeature feature\n\t\t\t\t\u0026\u0026 !feature.Suppressed\n\t\t\t\t\u0026\u0026 feature.Material.Clamped == slot )\n\t\t\t\tfound = feature;\n\t\t}\n\n\t\treturn found;\n\t}\n\n\t/// \u003Csummary\u003EEvery slot the document has an opinion about \u2014 one an assignment uses, or one\n\t/// somebody has named. What a menu offers on top of this is the menu\u0027s business.\u003C/summary\u003E\n\tpublic static IEnumerable\u003Cint\u003E UsedSlots( PartStudio studio )\n\t{\n\t\tvar slots = new SortedSet\u003Cint\u003E();\n\n\t\tif ( studio is null )\n\t\t\treturn slots;\n\n\t\tforeach ( var feature in studio.Features.OfType\u003CFaceMaterialFeature\u003E() )\n\t\t\tslots.Add( feature.Material.Clamped );\n\n\t\tforeach ( var slot in studio.MaterialNames.Keys )\n\t\t\tslots.Add( slot );\n\n\t\treturn slots;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Sketch/FacePlane.cs","FileName":"FacePlane.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// A reference to a face of some body, stored as geometry rather than as an index.\n///\n/// THIS IS THE WHOLE POINT OF THE TYPE. FreeCAD refers to sub-elements by name \u2014 \u0022Face6\u0022 \u2014 and\n/// that name comes from the shape\u0027s element ordering, which changes whenever anything upstream\n/// changes. A pocket attached to Face6 silently moves to a different face after an unrelated edit.\n/// It is the topological naming problem and it is their best-known long-running defect.\n///\n/// A point on the face plus its normal can be RE-FOUND after a rebuild. It survives any edit that\n/// does not destroy the face, and it degrades honestly when one does \u2014 nothing matches, and the\n/// feature says so, rather than silently attaching somewhere else.\n///\n/// Same principle as SketchConsumingFeature.RegionSeed, for the same reason.\n///\n/// BUT PURE GEOMETRY IS NOT ENOUGH ON ITS OWN, and a test caught that. A point and a normal survive\n/// an unrelated edit upstream perfectly, and break the moment the referenced face ITSELF moves \u2014\n/// make the box taller and the stored point is no longer anywhere near its top face. FreeCAD\u0027s\n/// \u0022Face6\u0022 has the opposite failure: it follows a face that moves, and silently jumps to a\n/// different one when the ordering changes.\n///\n/// So the reference carries the BODY it was taken from as well. Body ids are already kept stable\n/// across rebuilds for exactly this kind of use (see FeatureContext.SeedIdCounter). Resolution is\n/// then: find that body, take the faces pointing the right way, and among those pick the one\n/// nearest the stored point. The point disambiguates between candidates rather than acting as a\n/// hard constraint, which is what lets the face move and still be found.\n/// \u003C/summary\u003E\npublic readonly struct FaceRef\n{\n\t/// \u003Csummary\u003EWhich body the face belongs to. Ids are stable across rebuilds.\u003C/summary\u003E\n\tpublic readonly string BodyId;\n\n\t/// \u003Csummary\u003EA point on the face when it was chosen, in model space. Used to pick between faces\n\t/// of the same body pointing the same way \u2014 not as an exact test, so the face may move.\u003C/summary\u003E\n\tpublic readonly Vec3 Point;\n\n\t/// \u003Csummary\u003E\n\t/// Where on the face the sketch sits, as a distance IN FROM THE FACE\u0027S NEAREST EDGE along each\n\t/// of the face\u0027s own axes. This is what makes a sketch ride its face.\n\t///\n\t/// Three rules were possible and only this one matches what people mean. Anchoring to the\n\t/// absolute point ties the sketch to the face\u0027s infinite PLANE: shorten an extrude and its own\n\t/// side faces shrink away from underneath the sketch, leaving everything built on it hanging in\n\t/// the air. Anchoring to the CENTROID follows the face but only halfway \u2014 a tab placed 10 units\n\t/// in from the end of a 125-long face is 52.5 from the centre, and staying 52.5 from the centre\n\t/// of a 75-long face puts it 15 units past the end. Anchoring in from the nearest edge keeps\n\t/// \u002210 units in from the end\u0022 true at any length, which is the thing that was actually meant.\n\t/// \u003C/summary\u003E\n\tpublic readonly Vec2 Anchor;\n\n\t/// \u003Csummary\u003EWhich side of the face each axis of \u003Csee cref=\u0022Anchor\u0022/\u003E is measured from: false is\n\t/// the low edge, true the high one. Whichever the sketch was nearer to when it was placed \u2014 a\n\t/// tab near the end of a bar holds its distance from THAT end, not from the far one.\u003C/summary\u003E\n\tpublic readonly bool AnchorFromMaxX;\n\tpublic readonly bool AnchorFromMaxY;\n\n\t/// \u003Csummary\u003EFalse for a reference made before an anchor was recorded, which falls back to\n\t/// sitting at the centre of whatever face it resolves to. Without the distinction those\n\t/// references would read a (0,0) anchor as \u0022hard against the bottom-left corner\u0022.\u003C/summary\u003E\n\tpublic readonly bool Anchored;\n\n\t/// \u003Csummary\u003EThe face\u0027s outward normal, which disambiguates the two faces of a thin wall that\n\t/// a point alone would not tell apart.\u003C/summary\u003E\n\tpublic readonly Vec3 Normal;\n\n\tpublic FaceRef( string bodyId, Vec3 point, Vec3 normal )\n\t{\n\t\tBodyId = bodyId;\n\t\tPoint = point;\n\t\tNormal = normal.Normal;\n\t\tAnchor = Vec2.Zero;\n\t\tAnchorFromMaxX = false;\n\t\tAnchorFromMaxY = false;\n\t\tAnchored = false;\n\t}\n\n\tpublic FaceRef( string bodyId, Vec3 point, Vec3 normal, Vec2 anchor, bool fromMaxX, bool fromMaxY )\n\t{\n\t\tBodyId = bodyId;\n\t\tPoint = point;\n\t\tNormal = normal.Normal;\n\t\tAnchor = anchor;\n\t\tAnchorFromMaxX = fromMaxX;\n\t\tAnchorFromMaxY = fromMaxY;\n\t\tAnchored = true;\n\t}\n}\n\n/// \u003Csummary\u003E\n/// A reference to an edge of some body, stored as geometry rather than as a vertex-index pair.\n///\n/// THE SAME PROBLEM FaceRef SOLVES, FOR EDGES. An EdgeKey is two indices into a mesh that is\n/// thrown away every rebuild. Fillet the top of a box, then make the box taller, and those\n/// indices name a different edge \u2014 or none. A midpoint plus an undirected direction can be\n/// re-found: among edges pointing the same way, the nearest midpoint wins, so the edge can\n/// move with its face and still be the one that was picked.\n/// \u003C/summary\u003E\npublic readonly struct EdgeRef\n{\n\tpublic readonly string BodyId;\n\tpublic readonly Vec3 Point;\n\tpublic readonly Vec3 Direction;\n\n\tpublic EdgeRef( string bodyId, Vec3 point, Vec3 direction )\n\t{\n\t\tBodyId = bodyId;\n\t\tPoint = point;\n\t\tDirection = FacePlane.CanonicalDirection( direction );\n\t}\n}\n\n/// \u003Csummary\u003E\n/// Turning a face of an existing body into a plane you can sketch on.\n///\n/// Neither Solvespace nor FreeCAD treats \u0022sketch on a face\u0022 as a sketching mode: it is a DERIVED\n/// PLANE, and the sketcher then works exactly as it always does. Solvespace has workplane groups;\n/// FreeCAD has an Attacher that recomputes a placement from whatever it is attached to. This is\n/// Effigy\u0027s version, and it changes nothing about how sketching works.\n/// \u003C/summary\u003E\npublic static class FacePlane\n{\n\t/// \u003Csummary\u003E\n\t/// Build a sketch plane at a point with a given normal.\n\t///\n\t/// The in-plane axes are derived from the normal alone, deterministically, so the same face\n\t/// yields the same axes on every rebuild. Taking them from the face\u0027s own first edge would be\n\t/// tempting and wrong: edge order changes when the mesh is rebuilt, and the sketch would spin\n\t/// on its own plane while its coordinates stayed the same.\n\t/// \u003C/summary\u003E\n\tpublic static SketchPlane FromPointAndNormal( Vec3 point, Vec3 normal )\n\t{\n\t\tvar n = normal.Normal;\n\n\t\t// Cross with whichever world axis the normal is least aligned to, so the result never\n\t\t// collapses to zero length.\n\t\tvar seed = MathF.Abs( n.z ) \u003C 0.9f ? new Vec3( 0, 0, 1 ) : new Vec3( 1, 0, 0 );\n\n\t\tvar x = Vec3.Cross( seed, n ).Normal;\n\t\tvar y = Vec3.Cross( n, x ).Normal;\n\n\t\treturn new SketchPlane( point, x, y );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Capture a reference to the face that was just clicked, recording where on that face the\n\t/// click landed relative to its centroid. Use this rather than the FaceRef constructor\n\t/// directly: a reference built without an anchor sits at the centre of whatever face it\n\t/// resolves to, which is not where anyone clicked.\n\t/// \u003C/summary\u003E\n\tpublic static FaceRef Capture( Body body, int faceIndex, Vec3 point )\n\t{\n\t\tif ( body?.Mesh is not { } mesh || faceIndex \u003C 0 || faceIndex \u003E= mesh.Faces.Count )\n\t\t\treturn new FaceRef( body?.Id, point, new Vec3( 0, 0, 1 ) );\n\n\t\tvar face = mesh.Faces[faceIndex];\n\t\tvar normal = mesh.FaceNormal( face );\n\t\tvar centroid = mesh.FaceCentroid( face );\n\t\tvar plane = FromPointAndNormal( centroid, normal );\n\n\t\tvar bounds = Bounds( mesh, face, plane );\n\t\tvar local = plane.ToPlane( point );\n\n\t\t// Measured in from whichever edge it is nearer, per axis independently. A sketch near one\n\t\t// end and centred across the width therefore holds its inset from that end and stays\n\t\t// roughly central across the width, which is how it looks to whoever placed it.\n\t\tvar fromMaxX = local.x - bounds.MinX \u003E bounds.MaxX - local.x;\n\t\tvar fromMaxY = local.y - bounds.MinY \u003E bounds.MaxY - local.y;\n\n\t\tvar anchor = new Vec2(\n\t\t\tfromMaxX ? bounds.MaxX - local.x : local.x - bounds.MinX,\n\t\t\tfromMaxY ? bounds.MaxY - local.y : local.y - bounds.MinY );\n\n\t\treturn new FaceRef( body.Id, point, normal, anchor, fromMaxX, fromMaxY );\n\t}\n\n\t/// \u003Csummary\u003EA face\u0027s extent in its own plane axes. The axes come from the normal alone (see\n\t/// FromPointAndNormal), so this is the same box every rebuild for as long as the face points\n\t/// the same way.\u003C/summary\u003E\n\tstatic (float MinX, float MaxX, float MinY, float MaxY) Bounds( PolyMesh mesh, Face face, SketchPlane plane )\n\t{\n\t\tvar minX = float.MaxValue;\n\t\tvar maxX = float.MinValue;\n\t\tvar minY = float.MaxValue;\n\t\tvar maxY = float.MinValue;\n\n\t\tfor ( var i = 0; i \u003C face.Count; i\u002B\u002B )\n\t\t{\n\t\t\tvar p = plane.ToPlane( mesh.Positions[face.Indices[i]] );\n\n\t\t\tminX = MathF.Min( minX, p.x );\n\t\t\tmaxX = MathF.Max( maxX, p.x );\n\t\t\tminY = MathF.Min( minY, p.y );\n\t\t\tmaxY = MathF.Max( maxY, p.y );\n\t\t}\n\n\t\treturn (minX, maxX, minY, maxY);\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Find the face a reference points at, and return the plane to sketch on.\n\t///\n\t/// Matching is by geometry: the face\u0027s normal must agree with the reference\u0027s, and the\n\t/// reference point must lie on the face\u0027s plane. Among the faces that qualify, the one whose\n\t/// centroid is nearest the reference point wins \u2014 which is what keeps a reference on the right\n\t/// face of two coplanar ones.\n\t/// \u003C/summary\u003E\n\t/// \u003Csummary\u003E\n\t/// Find the face a reference points at: which body, and which face of it.\n\t///\n\t/// Split out of TryResolve because two different things now need to re-find a face \u2014 a sketch\n\t/// deriving a plane from it, and a material assignment painting it \u2014 and they must agree\n\t/// exactly about which face that is. Two copies of \u0022nearest face pointing the right way\u0022 that\n\t/// drifted apart would show up as a material landing on one face while the sketch drawn on it\n\t/// went somewhere else, which is not a failure anyone would enjoy diagnosing.\n\t/// \u003C/summary\u003E\n\tpublic static bool TryResolveFace( IEnumerable\u003CBody\u003E bodies, FaceRef reference, out Body body,\n\t\tout int faceIndex, float normalTolerance = 0.01f )\n\t{\n\t\tbody = null;\n\t\tfaceIndex = -1;\n\n\t\tif ( bodies is null )\n\t\t\treturn false;\n\n\t\t// Scoped to the body it came from. Without that, \u0022the point is no longer on the plane\u0022\n\t\t// either has to fail when the face moves, or has to search the whole model and risk\n\t\t// landing on some unrelated coplanar face.\n\t\tforeach ( var candidate in bodies )\n\t\t{\n\t\t\tif ( candidate?.Mesh is not null \u0026\u0026 candidate.Id == reference.BodyId )\n\t\t\t{\n\t\t\t\tbody = candidate;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tif ( body is null )\n\t\t\treturn false;\n\n\t\tvar bestDistance = float.MaxValue;\n\n\t\tfor ( var i = 0; i \u003C body.Mesh.Faces.Count; i\u002B\u002B )\n\t\t{\n\t\t\tvar face = body.Mesh.Faces[i];\n\n\t\t\tif ( face.Count \u003C 3 )\n\t\t\t\tcontinue;\n\n\t\t\tvar normal = body.Mesh.FaceNormal( face );\n\n\t\t\t// Same way up. A thin wall has two faces on nearly the same plane and only the normal\n\t\t\t// separates them.\n\t\t\tif ( Vec3.Dot( normal, reference.Normal ) \u003C 1f - normalTolerance )\n\t\t\t\tcontinue;\n\n\t\t\tvar distance = (body.Mesh.FaceCentroid( face ) - reference.Point).Length;\n\n\t\t\tif ( distance \u003E= bestDistance )\n\t\t\t\tcontinue;\n\n\t\t\tbestDistance = distance;\n\t\t\tfaceIndex = i;\n\t\t}\n\n\t\treturn faceIndex \u003E= 0;\n\t}\n\n\tpublic static bool TryResolve( IEnumerable\u003CBody\u003E bodies, FaceRef reference, out SketchPlane plane,\n\t\tfloat normalTolerance = 0.01f )\n\t{\n\t\tplane = null;\n\n\t\tif ( !TryResolveFace( bodies, reference, out var body, out var faceIndex, normalTolerance ) )\n\t\t\treturn false;\n\n\t\tvar bestFace = body.Mesh.Faces[faceIndex];\n\t\tvar bestOrigin = body.Mesh.FaceCentroid( bestFace );\n\t\tvar bestNormal = body.Mesh.FaceNormal( bestFace );\n\n\t\t// ANCHORED TO THE FACE\u0027S EDGES, NOT TO THE PLANE. The origin is rebuilt from the face\u0027s\n\t\t// CURRENT extent plus the stored inset, so a sketch placed ten units in from the end of a\n\t\t// bar is ten units in from the end however long the bar becomes. Projecting the stored\n\t\t// absolute point onto the plane instead (what this used to do) is identical whenever the\n\t\t// plane moves, and silently wrong whenever the face moves within a plane that does not -\n\t\t// which is exactly what shortening an extrude does to its own side faces.\n\t\tvar axes = FromPointAndNormal( bestOrigin, bestNormal );\n\t\tvar local = Vec2.Zero;\n\n\t\tif ( reference.Anchored )\n\t\t{\n\t\t\tvar bounds = Bounds( body.Mesh, bestFace, axes );\n\n\t\t\tlocal = new Vec2(\n\t\t\t\treference.AnchorFromMaxX ? bounds.MaxX - reference.Anchor.x : bounds.MinX \u002B reference.Anchor.x,\n\t\t\t\treference.AnchorFromMaxY ? bounds.MaxY - reference.Anchor.y : bounds.MinY \u002B reference.Anchor.y );\n\t\t}\n\n\t\tvar origin = bestOrigin \u002B axes.XAxis * local.x \u002B axes.YAxis * local.y;\n\n\t\tplane = FromPointAndNormal( origin, bestNormal );\n\t\treturn true;\n\t}\n\n\t/// \u003Csummary\u003EA direction with a stable sign, so an edge walked A\u2192B and B\u2192A is the same\n\t/// reference. The largest-magnitude component is made non-negative; ties fall back along Y\n\t/// then X.\u003C/summary\u003E\n\tpublic static Vec3 CanonicalDirection( Vec3 direction )\n\t{\n\t\tvar d = direction.Normal;\n\n\t\tif ( d.z \u003C -1e-6f\n\t\t\t|| (MathF.Abs( d.z ) \u003C= 1e-6f \u0026\u0026 d.y \u003C -1e-6f)\n\t\t\t|| (MathF.Abs( d.z ) \u003C= 1e-6f \u0026\u0026 MathF.Abs( d.y ) \u003C= 1e-6f \u0026\u0026 d.x \u003C 0f) )\n\t\t\td = new Vec3( -d.x, -d.y, -d.z );\n\n\t\treturn d;\n\t}\n\n\t/// \u003Csummary\u003ECapture the edge that was just clicked. Midpoint plus direction, so a later\n\t/// rebuild can re-find it the way Capture does for a face.\u003C/summary\u003E\n\tpublic static EdgeRef Capture( Body body, EdgeKey key )\n\t{\n\t\tif ( body?.Mesh is not { } mesh\n\t\t\t|| key.A \u003C 0 || key.A \u003E= mesh.Positions.Count\n\t\t\t|| key.B \u003C 0 || key.B \u003E= mesh.Positions.Count )\n\t\t\treturn new EdgeRef( body?.Id, Vec3.Zero, new Vec3( 1, 0, 0 ) );\n\n\t\tvar a = mesh.Positions[key.A];\n\t\tvar b = mesh.Positions[key.B];\n\n\t\treturn new EdgeRef( body.Id, (a \u002B b) * 0.5f, b - a );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Every unique edge of a face, captured as EdgeRefs. Selecting a face and then Fillet means\n\t/// \u0022these edges\u0022 in Onshape, and this is that translation.\n\t///\n\t/// THE SURFACE\u0027S EDGES, NOT THE n-GON\u0027S. On a wall a boolean has returned as fragments this\n\t/// used to hand back one triangle\u0027s three sides, two of which are seams in the middle of the\n\t/// wall - so \u0022select the wall, then Fillet\u0022 rounded a third of one edge of it and blended\n\t/// nothing along two lines drawn across the flat. FaceSurface is the same rule the viewport\n\t/// highlighted the wall with, so what gets blended is what was lit.\n\t/// \u003C/summary\u003E\n\tpublic static List\u003CEdgeRef\u003E CaptureBoundary( Body body, int faceIndex )\n\t{\n\t\tvar list = new List\u003CEdgeRef\u003E();\n\n\t\tif ( body?.Mesh is not { } mesh || faceIndex \u003C 0 || faceIndex \u003E= mesh.Faces.Count )\n\t\t\treturn list;\n\n\t\tvar surface = FaceSurface.FromFace( mesh, faceIndex );\n\t\tvar seen = new HashSet\u003CEdgeKey\u003E();\n\n\t\tforeach ( var (a, b) in surface.Boundary )\n\t\t{\n\t\t\tvar key = new EdgeKey( a, b );\n\n\t\t\tif ( !seen.Add( key ) )\n\t\t\t\tcontinue;\n\n\t\t\tlist.Add( Capture( body, key ) );\n\t\t}\n\n\t\treturn list;\n\t}\n\n\t/// \u003Csummary\u003ERe-find the edge a reference points at, on the body it was taken from.\u003C/summary\u003E\n\tpublic static bool TryResolveEdge( IEnumerable\u003CBody\u003E bodies, EdgeRef reference, out Body body,\n\t\tout EdgeKey key, float directionTolerance = 0.02f )\n\t{\n\t\tbody = null;\n\t\tkey = default;\n\n\t\tif ( bodies is null )\n\t\t\treturn false;\n\n\t\tforeach ( var candidate in bodies )\n\t\t{\n\t\t\tif ( candidate?.Mesh is not null \u0026\u0026 candidate.Id == reference.BodyId )\n\t\t\t{\n\t\t\t\tbody = candidate;\n\t\t\t\tbreak;\n\t\t\t}\n\t\t}\n\n\t\tif ( body is null )\n\t\t\treturn false;\n\n\t\tvar want = CanonicalDirection( reference.Direction );\n\t\tvar bestDistance = float.MaxValue;\n\t\tvar found = false;\n\n\t\tforeach ( var (edge, _) in body.Mesh.BuildEdgeFaces() )\n\t\t{\n\t\t\tvar a = body.Mesh.Positions[edge.A];\n\t\t\tvar b = body.Mesh.Positions[edge.B];\n\t\t\tvar dir = CanonicalDirection( b - a );\n\n\t\t\tif ( Vec3.Dot( dir, want ) \u003C 1f - directionTolerance )\n\t\t\t\tcontinue;\n\n\t\t\tvar mid = (a \u002B b) * 0.5f;\n\t\t\tvar distance = (mid - reference.Point).Length;\n\n\t\t\tif ( distance \u003E= bestDistance )\n\t\t\t\tcontinue;\n\n\t\t\tbestDistance = distance;\n\t\t\tkey = edge;\n\t\t\tfound = true;\n\t\t}\n\n\t\treturn found;\n\t}\n\n\t/// \u003Csummary\u003EThe subset of \u003Cparamref name=\u0022references\u0022/\u003E that still exist on this one body.\n\t/// Fillet walks bodies one at a time; an edge on a different part is not this body\u0027s\n\t/// problem.\u003C/summary\u003E\n\tpublic static HashSet\u003CEdgeKey\u003E ResolveEdges( Body body, IEnumerable\u003CEdgeRef\u003E references )\n\t{\n\t\tvar keys = new HashSet\u003CEdgeKey\u003E();\n\n\t\tif ( body is null || references is null )\n\t\t\treturn keys;\n\n\t\tvar list = new[] { body };\n\n\t\tforeach ( var reference in references )\n\t\t{\n\t\t\tif ( TryResolveEdge( list, reference, out _, out var key ) )\n\t\t\t\tkeys.Add( key );\n\t\t}\n\n\t\treturn keys;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Editor/Effigy/Sketch/SketchReference.cs","FileName":"SketchReference.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// Geometry the sketch does not own, projected into its plane so it can be seen and snapped to.\n///\n/// WHAT THIS IS FOR. A sketch on the face of an existing part is nearly always ABOUT that face \u2014\n/// a boss centred on it, a pocket set in from one of its corners, a rib running along one of its\n/// edges. Until this existed the face went blank the moment the sketcher opened: the plane was\n/// derived from it and then nothing about it was drawn or snappable, so lining a new rectangle up\n/// with the edge directly underneath it was done by eye against the shaded solid. That is exactly\n/// the kind of \u0022close enough\u0022 that turns into a 0.03-unit sliver after an extrude.\n///\n/// EVERY OTHER CAD PACKAGE CALLS THIS PROJECTED REFERENCE GEOMETRY and makes you ask for it a\n/// curve at a time \u2014 Onshape\u0027s Use tool, SolidWorks\u0027 Convert Entities. Effigy shows the whole\n/// face\u0027s boundary automatically instead, because the face was CHOSEN as the plane a moment ago,\n/// which is a much stronger statement of intent than clicking one edge of it.\n///\n/// IT IS NOT PART OF THE SKETCH. Nothing here is in Sketch.Points or Sketch.Curves, so it never\n/// reaches ProfileFinder, never extrudes, and is never saved. It is rebuilt from the model every\n/// time the sketch is opened, which is what keeps it honest when the face underneath changes \u2014\n/// the same reason FaceRef stores geometry rather than \u0022Face6\u0022.\n/// \u003C/summary\u003E\npublic sealed class SketchReference\n{\n\t/// \u003Csummary\u003ECorners of the referenced geometry, in sketch-plane coordinates.\u003C/summary\u003E\n\tpublic readonly List\u003CVec2\u003E Points = new();\n\n\t/// \u003Csummary\u003EEdges, as index pairs into \u003Csee cref=\u0022Points\u0022/\u003E.\u003C/summary\u003E\n\tpublic readonly List\u003C(int A, int B)\u003E Edges = new();\n\n\tpublic bool IsEmpty =\u003E Points.Count == 0;\n\n\t/// \u003Csummary\u003EAn edge as the two positions it runs between. Bounds-checked to a zero-length\n\t/// segment rather than throwing: this is drawn and hit-tested every frame from an index that a\n\t/// rebuild underneath could have invalidated, and a viewport that throws once per frame is\n\t/// worse than one that briefly draws nothing.\u003C/summary\u003E\n\tpublic (Vec2 A, Vec2 B) Segment( int index )\n\t{\n\t\tif ( index \u003C 0 || index \u003E= Edges.Count )\n\t\t\treturn (Vec2.Zero, Vec2.Zero);\n\n\t\tvar (a, b) = Edges[index];\n\n\t\tif ( a \u003C 0 || a \u003E= Points.Count || b \u003C 0 || b \u003E= Points.Count )\n\t\t\treturn (Vec2.Zero, Vec2.Zero);\n\n\t\treturn (Points[a], Points[b]);\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Copy one reference edge into the sketch as a real line \u2014 Onshape\u0027s Use, one edge at a time.\n\t///\n\t/// WHY A COPY AND NOT A LIVE LINK. Onshape\u0027s projected curves stay attached to what they were\n\t/// taken from and move when it moves. That is the better behaviour and it is also a whole\n\t/// feature: the sketch would need a second class of curve that the user cannot drag or delete,\n\t/// that ProfileFinder unions in, and that is rebuilt rather than saved. A copy is what the tool\n\t/// does here, and it is honest about it \u2014 the line becomes ordinary sketch geometry, yours to\n\t/// trim and drag, and it does NOT follow the face afterwards.\n\t///\n\t/// Points are reused through SketchSnapper.PointIndex, so an edge copied in welds onto whatever\n\t/// is already at its ends rather than laying a second point on top of the first. That is what\n\t/// makes \u0022use all four edges, then draw a line across\u0022 close into two regions instead of into\n\t/// nothing at all.\n\t/// \u003C/summary\u003E\n\t/// \u003Creturns\u003EThe line added, or null when the edge is degenerate or the sketch already has it.\u003C/returns\u003E\n\tpublic SketchLine UseEdge( Sketch sketch, int edgeIndex )\n\t{\n\t\tif ( sketch is null || edgeIndex \u003C 0 || edgeIndex \u003E= Edges.Count )\n\t\t\treturn null;\n\n\t\tvar (a, b) = Segment( edgeIndex );\n\n\t\tvar start = SketchSnapper.PointIndex( sketch, a );\n\t\tvar end = SketchSnapper.PointIndex( sketch, b );\n\n\t\t// A zero-length line is not geometry - ProfileFinder links it into the adjacency map twice\n\t\t// at one point and calls the sketch branching. The line tool has the same guard.\n\t\tif ( start == end )\n\t\t\treturn null;\n\n\t\t// USING THE SAME EDGE TWICE MUST NOT LAY A SECOND LINE ON THE FIRST. Two curves between the\n\t\t// same pair of points is exactly the branching case ProfileFinder refuses, so clicking an\n\t\t// edge you already used would quietly destroy the profile you were building.\n\t\tforeach ( var curve in sketch.Curves )\n\t\t{\n\t\t\tvar (from, to) = curve.Endpoints;\n\n\t\t\tif ( (from == start \u0026\u0026 to == end) || (from == end \u0026\u0026 to == start) )\n\t\t\t\treturn null;\n\t\t}\n\n\t\treturn sketch.Add( new SketchLine( start, end ) );\n\t}\n\n\t/// \u003Csummary\u003ECopy every reference edge in, which is the common case: the whole face outline, so a\n\t/// single line drawn across it closes two regions. Returns how many were added \u2014 edges already\n\t/// in the sketch are skipped, so running it twice is harmless.\u003C/summary\u003E\n\tpublic int UseAll( Sketch sketch )\n\t{\n\t\tvar added = 0;\n\n\t\tfor ( var i = 0; i \u003C Edges.Count; i\u002B\u002B )\n\t\t{\n\t\t\tif ( UseEdge( sketch, i ) is not null )\n\t\t\t\tadded\u002B\u002B;\n\t\t}\n\n\t\treturn added;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The boundary of the face a sketch is attached to, in that sketch\u0027s plane.\n\t///\n\t/// THE BOUNDARY OF THE SURFACE, NOT OF ONE n-GON. A face that has been through a boolean is\n\t/// usually several faces sharing a plane, and outlining each of them separately draws the seams\n\t/// where they were split \u2014 lines that are not edges of anything, sitting in the middle of what\n\t/// looks like one flat surface, and snapping to them is snapping to an artefact of how the mesh\n\t/// happens to be cut up. FaceSurface is what decides where the surface stops, and it is the\n\t/// same answer the viewport highlights and the edge picker offers: three things that used to\n\t/// each work it out for themselves and disagree on screen at the same time.\n\t///\n\t/// PROJECTED, NOT INTERSECTED, so a sketch with an offset still gets the face\u0027s outline \u2014\n\t/// directly below where it will be drawn, which is what makes the offset useful for a boss\n\t/// standing clear of the surface it grows from.\n\t/// \u003C/summary\u003E\n\tpublic static SketchReference FromFace( IEnumerable\u003CBody\u003E bodies, FaceRef reference, SketchPlane plane )\n\t{\n\t\tvar result = new SketchReference();\n\n\t\tif ( plane is null || !FacePlane.TryResolveFace( bodies, reference, out var body, out var faceIndex ) )\n\t\t\treturn result;\n\n\t\tvar mesh = body.Mesh;\n\n\t\t// Scaled to the part, for the same reason every other tolerance in the sketcher is: a\n\t\t// constant that is generous on a 100-unit block silently merges every vertex of a 0.1-unit\n\t\t// one. See SketchSnapper\u0027s header for what fixed tolerances did to this sketcher.\n\t\tvar tolerance = MathF.Max( mesh.BoundsDiagonal * 1e-4f, 1e-5f );\n\n\t\tvar surface = FaceSurface.FromFace( mesh, faceIndex );\n\t\tvar mapped = new Dictionary\u003Cint, int\u003E();\n\n\t\tforeach ( var (from, to) in surface.Boundary )\n\t\t{\n\t\t\tvar a = Map( result, mapped, mesh, plane, tolerance, from );\n\t\t\tvar b = Map( result, mapped, mesh, plane, tolerance, to );\n\n\t\t\tif ( a != b )\n\t\t\t\tresult.Edges.Add( (a, b) );\n\t\t}\n\n\t\treturn result;\n\t}\n\n\t/// \u003Csummary\u003EMesh vertex to reference point, projected and de-duplicated. Two mesh vertices at\n\t/// the same position \u2014 which a boolean leaves behind routinely \u2014 must become ONE snap target,\n\t/// or the corner of the face has two dots on it and the cursor picks between them at\n\t/// random.\u003C/summary\u003E\n\tstatic int Map( SketchReference result, Dictionary\u003Cint, int\u003E mapped, PolyMesh mesh,\n\t\tSketchPlane plane, float tolerance, int vertex )\n\t{\n\t\tif ( mapped.TryGetValue( vertex, out var existing ) )\n\t\t\treturn existing;\n\n\t\tvar p = plane.ToPlane( mesh.Positions[vertex] );\n\n\t\tfor ( var i = 0; i \u003C result.Points.Count; i\u002B\u002B )\n\t\t{\n\t\t\tif ( (result.Points[i] - p).LengthSquared \u003E tolerance * tolerance )\n\t\t\t\tcontinue;\n\n\t\t\tmapped[vertex] = i;\n\t\t\treturn i;\n\t\t}\n\n\t\tresult.Points.Add( p );\n\t\tmapped[vertex] = result.Points.Count - 1;\n\n\t\treturn result.Points.Count - 1;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Editor/EffigyEditor/EffigyIcons.cs","FileName":"EffigyIcons.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using Editor;\nusing System.Collections.Generic;\nusing Sandbox;\nusing System;\n\nnamespace Marionette.EditorTools;\n\n/// \u003Csummary\u003EWhich drawing a feature-tool button paints. One per creation tool.\u003C/summary\u003E\ninternal enum EffigyIcon\n{\n\tSketch,\n\tPrimitive,\n\tExtrude,\n\tRevolve,\n\tSweep,\n\tLoft,\n\tChamfer,\n\tFillet,\n\tShell,\n\tSubdivide,\n\tMirror,\n\tLinearPattern,\n\tCircularPattern,\n\tTransform,\n\tUVProject,\n\tFaceMaterial,\n\n\t// --- sketch tools -------------------------------------------------------------------------\n\t// These were Material Icon NAMES until now, and generic ones: show_chart (a zigzag line chart)\n\t// for Line, cached (two refresh arrows) for Arc, crop_square for Rectangle. They said nothing\n\t// about the operation, and half of them said something actively misleading.\n\tSelectTool,\n\tLineTool,\n\tLineMidpointTool,\n\tRectangleTool,\n\tRectangleCentreTool,\n\tCircleTool,\n\tCircleThreePointTool,\n\tArcTool,\n\tArcThreePointTool,\n\tPolygonTool,\n\tPolygonCircumscribedTool,\n\tSlotTool,\n\tPointTool,\n\tConstructionTool,\n\tProfileInspectorTool,\n\tFinishSketchTool,\n\n\t// --- sculpt tools -------------------------------------------------------------------------\n\t// The brushes are drawn as what they DO to a surface rather than as tool shapes: a row of six\n\t// identical brush heads distinguished by a tiny badge is six ways to pick the wrong one. Every\n\t// glyph here is a surface line and what the brush does to it.\n\tSculpt,\n\tSculptDraw,\n\tSculptSmooth,\n\tSculptInflate,\n\tSculptGrab,\n\tSculptFlatten,\n\tSculptPinch,\n\tSculptMask,\n\tSculptLevelDown,\n\tSculptLevelUp,\n\tSculptBake,\n\n\t// --- solid tools that act on picked faces ---------------------------------------------------\n\tDraft,\n\tMoveFace,\n\tHole,\n\n\t// --- the six sketch tools whose kernel half was finished first ------------------------------\n\tEllipseTool,\n\tSplineTool,\n\tTrimTool,\n\tExtendTool,\n\tSketchFilletTool,\n\tOffsetTool,\n\n\t// --- taking the face\u0027s own outline into the sketch -------------------------------------------\n\tUseTool,\n\tUseAllTool,\n\n\t// --- the one sketch tool driven by a drag ----------------------------------------------------\n\tCutTool,\n\n\t// --- grease pencil: annotation, not geometry -------------------------------------------------\n\t// Both are drawn as the real-world objects rather than as marks, because that is the one thing\n\t// that says \u0022this is not a modelling operation\u0022 before the tooltip gets a chance to. Every other\n\t// glyph in the bar is a shape being changed; these are stationery.\n\tNoteTool,\n\tNoteEraseTool,\n\n\t// --- lighting: viewport scenery, not geometry ------------------------------------------------\n\t// Drawn as lamps and as what a lamp DOES to a shape, for the same reason the notes above are\n\t// drawn as stationery: nothing on this stage changes the model, and the glyphs should say so\n\t// before the tooltip gets a chance to. The three rigs are the same sphere lit three ways, which\n\t// is the only honest way to draw the difference between them.\n\tLightFullBright,\n\tLightPoint,\n\tLightSpot,\n\tLightSun,\n\tLightRigThreePoint,\n\tLightRigRim,\n\tLightRigTop,\n\tLightRigKey,\n\tLightClear,\n}\n\n/// \u003Csummary\u003E\n/// Painted icons for the feature-creation strip, drawn rather than looked up in a font.\n///\n/// Same reasoning as RigIconButton (see Editor/RigControlEditor): s\u0026box ships CLASSIC Material\n/// Icons, not the newer Material Symbols, so a name from the Symbols set silently renders as\n/// nothing \u2014 and the strip was leaning on generic names like \u0022square\u0022, \u0022flip\u0022 and \u0022call_made\u0022\n/// that, where they resolved at all, said nothing about the CAD operation behind them. A drawn\n/// glyph can show the actual operation: Chamfer cuts a corner off a square, Shell puts a wall\n/// inside one, Mirror reflects a solid shape into an outlined one.\n///\n/// Every icon is drawn around \u003Cc\u003Ecenter\u003C/c\u003E inside a nominal 18x18 box, so they all read at the\n/// same weight, then scaled up as one by the \u003Cc\u003Escale\u003C/c\u003E argument to fit the button drawing it.\n/// \u003C/summary\u003E\ninternal static class EffigyIcons\n{\n\t/// \u003Csummary\u003EStroke width every outline uses, so no icon looks heavier than its neighbours.\u003C/summary\u003E\n\tprivate const float Stroke = 1.6f;\n\n\t// --- the pencil\u0027s own colours -------------------------------------------------------------\n\t//\n\t// The ONLY icon that does not draw entirely in the colour it is handed. Sketch is the tool\n\t// every part starts with and the only button in the strip carrying a text label, so it is the\n\t// one worth making findable at a glance rather than another grey glyph in a row of grey\n\t// glyphs. A yellow #2 is about as legible as a small object gets.\n\t//\n\t// Chosen against a dark viewport: the graphite is a mid grey rather than near-black, because a\n\t// true graphite point disappears into the background exactly where the icon needs to read.\n\n\tprivate static readonly Color PencilBody = new( 0.96f, 0.76f, 0.15f );\n\tprivate static readonly Color PencilFerrule = new( 0.74f, 0.77f, 0.80f );\n\tprivate static readonly Color PencilEraser = new( 0.91f, 0.56f, 0.58f );\n\tprivate static readonly Color PencilWood = new( 0.87f, 0.68f, 0.44f );\n\tprivate static readonly Color PencilGraphite = new( 0.45f, 0.47f, 0.50f );\n\n\t/// \u003Csummary\u003EMultiplier applied to every coordinate, radius and pen width for the icon being\n\t/// drawn right now. Every glyph is authored against the nominal 18x18 box, so one factor set\n\t/// here at the top of Draw is enough to resize all of them together \u2014 the strip\u0027s buttons grew\n\t/// past the size the glyphs were drawn for and a fixed-size glyph in a big button reads as a\n\t/// mistake. Painting only ever happens on the editor UI thread, so a plain static is safe.\u003C/summary\u003E\n\tprivate static float _scale = 1f;\n\n\tpublic static void Draw( EffigyIcon icon, Vector2 center, Color color, float scale = 1f )\n\t{\n\t\tEditor.Paint.Antialiasing = true;\n\t\t_scale = scale;\n\n\t\tswitch ( icon )\n\t\t{\n\t\t\tcase EffigyIcon.Sketch: PaintSketch( center, color ); return;\n\t\t\tcase EffigyIcon.Primitive: PaintPrimitive( center, color ); return;\n\t\t\tcase EffigyIcon.Extrude: PaintExtrude( center, color ); return;\n\t\t\tcase EffigyIcon.Revolve: PaintRevolve( center, color ); return;\n\t\t\tcase EffigyIcon.Sweep: PaintSweep( center, color ); return;\n\t\t\tcase EffigyIcon.Loft: PaintLoft( center, color ); return;\n\t\t\tcase EffigyIcon.Chamfer: PaintChamfer( center, color ); return;\n\t\t\tcase EffigyIcon.Fillet: PaintFillet( center, color ); return;\n\t\t\tcase EffigyIcon.Shell: PaintShell( center, color ); return;\n\t\t\tcase EffigyIcon.Subdivide: PaintSubdivide( center, color ); return;\n\t\t\tcase EffigyIcon.Mirror: PaintMirror( center, color ); return;\n\t\t\tcase EffigyIcon.LinearPattern: PaintLinearPattern( center, color ); return;\n\t\t\tcase EffigyIcon.CircularPattern: PaintCircularPattern( center, color ); return;\n\t\t\tcase EffigyIcon.Transform: PaintTransform( center, color ); return;\n\t\t\tcase EffigyIcon.UVProject: PaintUVProject( center, color ); return;\n\t\t\tcase EffigyIcon.FaceMaterial: PaintFaceMaterial( center, color ); return;\n\n\t\t\tcase EffigyIcon.SelectTool: PaintSelectTool( center, color ); return;\n\t\t\tcase EffigyIcon.LineTool: PaintLineTool( center, color ); return;\n\t\t\tcase EffigyIcon.LineMidpointTool: PaintLineMidpointTool( center, color ); return;\n\t\t\tcase EffigyIcon.RectangleTool: PaintRectangleTool( center, color ); return;\n\t\t\tcase EffigyIcon.RectangleCentreTool: PaintRectangleCentreTool( center, color ); return;\n\t\t\tcase EffigyIcon.CircleTool: PaintCircleTool( center, color ); return;\n\t\t\tcase EffigyIcon.CircleThreePointTool: PaintCircleThreePointTool( center, color ); return;\n\t\t\tcase EffigyIcon.ArcTool: PaintArcTool( center, color ); return;\n\t\t\tcase EffigyIcon.ArcThreePointTool: PaintArcThreePointTool( center, color ); return;\n\t\t\tcase EffigyIcon.PolygonTool: PaintPolygonTool( center, color ); return;\n\t\t\tcase EffigyIcon.PolygonCircumscribedTool: PaintPolygonCircumscribedTool( center, color ); return;\n\t\t\tcase EffigyIcon.SlotTool: PaintSlotTool( center, color ); return;\n\t\t\tcase EffigyIcon.PointTool: PaintPointTool( center, color ); return;\n\t\t\tcase EffigyIcon.ConstructionTool: PaintConstructionTool( center, color ); return;\n\t\t\tcase EffigyIcon.ProfileInspectorTool: PaintProfileInspectorTool( center, color ); return;\n\t\t\tcase EffigyIcon.FinishSketchTool: PaintFinishSketchTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.Sculpt: PaintSculpt( center, color ); return;\n\t\t\tcase EffigyIcon.SculptDraw: PaintSculptDraw( center, color ); return;\n\t\t\tcase EffigyIcon.SculptSmooth: PaintSculptSmooth( center, color ); return;\n\t\t\tcase EffigyIcon.SculptInflate: PaintSculptInflate( center, color ); return;\n\t\t\tcase EffigyIcon.SculptGrab: PaintSculptGrab( center, color ); return;\n\t\t\tcase EffigyIcon.SculptFlatten: PaintSculptFlatten( center, color ); return;\n\t\t\tcase EffigyIcon.SculptPinch: PaintSculptPinch( center, color ); return;\n\t\t\tcase EffigyIcon.SculptMask: PaintSculptMask( center, color ); return;\n\t\t\tcase EffigyIcon.SculptLevelDown: PaintSculptLevelDown( center, color ); return;\n\t\t\tcase EffigyIcon.SculptLevelUp: PaintSculptLevelUp( center, color ); return;\n\t\t\tcase EffigyIcon.SculptBake: PaintSculptBake( center, color ); return;\n\n\t\t\tcase EffigyIcon.Draft: PaintDraft( center, color ); return;\n\t\t\tcase EffigyIcon.MoveFace: PaintMoveFace( center, color ); return;\n\t\t\tcase EffigyIcon.Hole: PaintHole( center, color ); return;\n\n\t\t\tcase EffigyIcon.NoteTool: PaintNoteTool( center, color ); return;\n\t\t\tcase EffigyIcon.NoteEraseTool: PaintNoteEraseTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.LightFullBright: PaintLightFullBright( center, color ); return;\n\t\t\tcase EffigyIcon.LightPoint: PaintLightPoint( center, color ); return;\n\t\t\tcase EffigyIcon.LightSpot: PaintLightSpot( center, color ); return;\n\t\t\tcase EffigyIcon.LightSun: PaintLightSun( center, color ); return;\n\t\t\tcase EffigyIcon.LightRigThreePoint: PaintLightRigThreePoint( center, color ); return;\n\t\t\tcase EffigyIcon.LightRigRim: PaintLightRigRim( center, color ); return;\n\t\t\tcase EffigyIcon.LightRigTop: PaintLightRigTop( center, color ); return;\n\t\t\tcase EffigyIcon.LightRigKey: PaintLightRigKey( center, color ); return;\n\t\t\tcase EffigyIcon.LightClear: PaintLightClear( center, color ); return;\n\n\t\t\tcase EffigyIcon.EllipseTool: PaintEllipseTool( center, color ); return;\n\t\t\tcase EffigyIcon.SplineTool: PaintSplineTool( center, color ); return;\n\t\t\tcase EffigyIcon.TrimTool: PaintTrimTool( center, color ); return;\n\t\t\tcase EffigyIcon.ExtendTool: PaintExtendTool( center, color ); return;\n\t\t\tcase EffigyIcon.SketchFilletTool: PaintSketchFilletTool( center, color ); return;\n\t\t\tcase EffigyIcon.OffsetTool: PaintOffsetTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.UseTool: PaintUseTool( center, color ); return;\n\t\t\tcase EffigyIcon.UseAllTool: PaintUseAllTool( center, color ); return;\n\n\t\t\tcase EffigyIcon.CutTool: PaintCutTool( center, color ); return;\n\t\t}\n\t}\n\n\t// --- drawing helpers --------------------------------------------------------------------\n\n\tprivate static void Stroked( Color color, float width = Stroke )\n\t{\n\t\tEditor.Paint.ClearBrush();\n\t\tEditor.Paint.SetPen( color, width * _scale );\n\t}\n\n\tprivate static void Filled( Color color )\n\t{\n\t\tEditor.Paint.ClearPen();\n\t\tEditor.Paint.SetBrush( color );\n\t}\n\n\t/// \u003Csummary\u003EClosed outline through the given points \u2014 DrawPolygon fills, so an outlined shape\n\t/// has to be walked as lines.\u003C/summary\u003E\n\tprivate static void Outline( params Vector2[] points )\n\t{\n\t\tfor ( var i = 0; i \u003C points.Length; i\u002B\u002B )\n\t\t\tEditor.Paint.DrawLine( points[i], points[(i \u002B 1) % points.Length] );\n\t}\n\n\t/// \u003Csummary\u003EAn arc as a polyline. There is no arc primitive in Paint, and approximating with\n\t/// segments is exact enough at icon size.\u003C/summary\u003E\n\tprivate static void Arc( Vector2 center, float radius, float fromDegrees, float toDegrees, int segments = 14 )\n\t{\n\t\tvar previous = Vector2.Zero;\n\n\t\tfor ( var i = 0; i \u003C= segments; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = fromDegrees \u002B (toDegrees - fromDegrees) * (i / (float)segments);\n\t\t\tvar radians = t * MathF.PI / 180f;\n\t\t\tvar point = center \u002B new Vector2( MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) * _scale;\n\n\t\t\tif ( i \u003E 0 )\n\t\t\t\tEditor.Paint.DrawLine( previous, point );\n\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EAn elliptical arc as a polyline. Arc() draws a circle and cannot say \u0022this circle\n\t/// is lying flat\u0022; that foreshortening is the entire difference between a glyph that reads as\n\t/// a rotation about an axis and one that reads as a spiral.\u003C/summary\u003E\n\tprivate static void EllipseArc( Vector2 center, float radiusX, float radiusY,\n\t\tfloat fromDegrees, float toDegrees, int segments = 24 )\n\t{\n\t\tvar previous = Vector2.Zero;\n\n\t\tfor ( var i = 0; i \u003C= segments; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = fromDegrees \u002B (toDegrees - fromDegrees) * (i / (float)segments);\n\t\t\tvar radians = t * MathF.PI / 180f;\n\t\t\tvar point = center \u002B new Vector2( MathF.Cos( radians ) * radiusX, MathF.Sin( radians ) * radiusY ) * _scale;\n\n\t\t\tif ( i \u003E 0 )\n\t\t\t\tEditor.Paint.DrawLine( previous, point );\n\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EA solid triangular arrow head, pointing along \u003Cparamref name=\u0022direction\u0022/\u003E.\u003C/summary\u003E\n\tprivate static void ArrowHead( Vector2 tip, Vector2 direction, Color color, float size = 3.4f )\n\t{\n\t\tvar d = direction.Normal;\n\t\tvar side = new Vector2( -d.y, d.x );\n\n\t\tsize *= _scale;\n\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\ttip,\n\t\t\ttip - d * size \u002B side * size * 0.62f,\n\t\t\ttip - d * size - side * size * 0.62f );\n\t}\n\n\tprivate static Vector2 At( Vector2 center, float x, float y ) =\u003E center \u002B new Vector2( x, y ) * _scale;\n\n\t/// \u003Csummary\u003EA rect in the same nominal icon space At() uses, for the glyphs that need\n\t/// DrawRect/DrawCircle rather than a walked outline.\u003C/summary\u003E\n\tprivate static Rect Box( Vector2 center, float x, float y, float width, float height )\n\t\t=\u003E new Rect( center.x \u002B x * _scale, center.y \u002B y * _scale, width * _scale, height * _scale );\n\n\t// --- the icons --------------------------------------------------------------------------\n\n\t/// \u003Csummary\u003E\n\t/// A pencil drawing on a sheet, its point resting ON the paper\u0027s top edge.\n\t///\n\t/// The pencil used to be a plain parallelogram - blunt at both ends, with one of its corners\n\t/// landing on the paper line. A pencil reads as a pencil because of the cone at the end, and\n\t/// the mark reads as DRAWING because that cone touches the paper rather than hovering above\n\t/// it or crossing through it. So: a solid tapered point that ends exactly on the line, an\n\t/// outlined barrel behind it, and a band where the ferrule would be.\n\t///\n\t/// Every coordinate is derived from the tip and the pencil\u0027s axis, laid out along a 45 degree\n\t/// diagonal, so the point cannot drift off the paper if the proportions are adjusted.\n\t///\n\t/// The paper keeps the colour it is handed; the pencil does not (see PencilBody and friends).\n\t/// \u003C/summary\u003E\n\tprivate static void PaintSketch( Vector2 c, Color color )\n\t{\n\t\t// Paper: a single flat horizontal line, matching the compact reference glyph. Everything\n\t\t// else is placed against PaperY.\n\t\tconst float PaperY = 4.8f;\n\n\t\t// How thick the pencil is, across the barrel.\n\t\tconst float BarrelWidth = 1.7f;\n\n\t\tStroked( color, Stroke );\n\t\tEditor.Paint.DrawLine( At( c, -6.6f, PaperY ), At( c, 6.4f, PaperY ) );\n\n\t\t// The sharpened cone, sitting on the paper. Solid, because at 27px a hollow cone is a\n\t\t// smudge - the filled wedge is what makes it read as sharpened. Its base is exactly as\n\t\t// wide as the barrel\u0027s stroke, so the two meet without a step.\n\t\tFilled( PencilWood );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -4.4f, PaperY ),\n\t\t\tAt( c, -1.678f, 3.28f ),\n\t\t\tAt( c, -2.88f, 2.078f ) );\n\n\t\t// The exposed lead, the outer 40% of that cone. Drawn over the wood rather than beside it,\n\t\t// so the two always agree about where the point is.\n\t\tFilled( PencilGraphite );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -4.4f, PaperY ),\n\t\t\tAt( c, -3.257f, 4.161f ),\n\t\t\tAt( c, -3.761f, 3.657f ) );\n\n\t\t// Barrel: ONE STROKED LINE, not an outlined shape. A pencil this slim has a body 1.7 units\n\t\t// across, and two outline strokes inside that merge into a blob - the line IS the barrel,\n\t\t// and it is the only way to get a thin pencil that still reads at icon size. The ferrule\n\t\t// and eraser are further stretches of the same line, which is also why they cannot drift\n\t\t// out of alignment with it.\n\t\tStroked( PencilBody, BarrelWidth );\n\t\tEditor.Paint.DrawLine( At( c, -2.279f, 2.679f ), At( c, 4.156f, -3.756f ) );\n\n\t\tStroked( PencilFerrule, BarrelWidth );\n\t\tEditor.Paint.DrawLine( At( c, 4.156f, -3.756f ), At( c, 4.948f, -4.548f ) );\n\n\t\tStroked( PencilEraser, BarrelWidth );\n\t\tEditor.Paint.DrawLine( At( c, 4.948f, -4.548f ), At( c, 5.853f, -5.453f ) );\n\t}\n\n\t/// \u003Csummary\u003EAn isometric cube \u2014 the generic \u0022a solid body\u0022 mark.\u003C/summary\u003E\n\tprivate static void PaintPrimitive( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tOutline(\n\t\t\tAt( c, 0, -7.5f ), At( c, 7, -3.6f ), At( c, 7, 3.6f ),\n\t\t\tAt( c, 0, 7.5f ), At( c, -7, 3.6f ), At( c, -7, -3.6f ) );\n\n\t\t// The three edges meeting at the near corner are what make it read as a cube rather than\n\t\t// a hexagon.\n\t\tEditor.Paint.DrawLine( At( c, 0, 0 ), At( c, 0, 7.5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, 0 ), At( c, 7, -3.6f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, 0 ), At( c, -7, -3.6f ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A profile lying flat, and the solid pulled UP off it.\n\t///\n\t/// The old glyph had the profile on top with the arrow pointing down, which reads as something\n\t/// falling rather than as something being drawn out \u2014 and at toolbar size the whole thing came\n\t/// out looking like a plumb bob. Arrow and profile now agree about which way an extrude goes.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintExtrude( Vector2 c, Color color )\n\t{\n\t\t// The sketch, in plan, dimmed: it is what the operation starts FROM, not what it makes.\n\t\tStroked( color.WithAlpha( 0.55f ), 1.5f );\n\t\tOutline( At( c, -7.5f, 6.5f ), At( c, 0, 9.4f ), At( c, 7.5f, 6.5f ), At( c, 0, 3.6f ) );\n\n\t\tStroked( color, 2.6f );\n\t\tEditor.Paint.DrawLine( At( c, 0, 6.5f ), At( c, 0, -4.2f ) );\n\n\t\tArrowHead( At( c, 0, -8.6f ), new Vector2( 0, -1 ), color, 4.2f );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A sketch sitting on an axis, and the spin that turns it into a solid.\n\t///\n\t/// Extrude is a straight arrow off a profile. This is the same grammar bent into a C \u2014 axis,\n\t/// profile, curved arrow \u2014 which is what every CAD tool draws for Revolve and what the last\n\t/// version threw out. That version drew a vase in section and hoped the silhouette would\n\t/// carry it; at toolbar size it was a lumpy outline with a dashed line through its face.\n\t/// Fill the profile (same weight as Chamfer and Shell) and let the arrow be the operation.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintRevolve( Vector2 c, Color color )\n\t{\n\t\t// ONSHAPE\u0027S OWN REVOLVE ICON: a disc with a wedge taken out of it. po pointed at it, and it\n\t\t// is better than either drawing that came before.\n\t\t//\n\t\t// The first attempt stacked a dashed axis, a filled profile rectangle and a circular arrow\n\t\t// on top of each other; the arc swept straight through the rectangle it was meant to be\n\t\t// spinning and the three merged into a tall dark blob that read as the letter D. The\n\t\t// second replaced it with a lathe - profile beside an axis, ellipse sweeping round it -\n\t\t// which was legible but was three small shapes doing the work of one, and small shapes are\n\t\t// what stop reading first when the strip is the only chrome on a 3D viewport.\n\t\t//\n\t\t// A disc with a mouth is ONE shape. It shows the result rather than the mechanism, which\n\t\t// is the idiom the rest of this strip already uses - Chamfer cuts a corner off a square,\n\t\t// Shell puts a wall inside one. The mouth is what makes it a revolve rather than a circle:\n\t\t// the two cut faces meeting at the centre are the start and end of the sweep, and they say\n\t\t// \u0022this was swept through an angle\u0022 without needing an arrow to explain it. It is also\n\t\t// square and solid, so it holds its weight next to Extrude and Loft instead of being the\n\t\t// one thin glyph on the row.\n\n\t\tconst float Radius = 8.6f;\n\n\t\t// How much of the disc is missing. Wide enough to read as a deliberate mouth at 32px\n\t\t// rather than as a nick in the outline, narrow enough that the shape is still a disc.\n\t\tconst float MouthHalfAngle = 38f;\n\n\t\t// The rim, from one cut face round to the other, then in to the centre. Closing the loop\n\t\t// draws the second cut face, so the whole silhouette is one walk.\n\t\tvar rim = ArcPoints( c, Radius, MouthHalfAngle, 360f - MouthHalfAngle, 30 );\n\t\trim.Add( c );\n\n\t\tvar silhouette = rim.ToArray();\n\n\t\tFilled( color.WithAlpha( 0.22f ) );\n\t\tEditor.Paint.DrawPolygon( silhouette );\n\n\t\tStroked( color, 1.6f );\n\t\tOutline( silhouette );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A profile carried along a path, with the path drawn as the thing that shapes it.\n\t///\n\t/// Sweep and Extrude are the same sentence with a different verb \u2014 a profile, and where it goes\n\t/// \u2014 so they are drawn with the same grammar: the starting profile dim because it is what the\n\t/// operation begins FROM rather than what it makes, and an arrow for the operation itself. The\n\t/// difference between them is the whole point, so here the path is a curve and the solid\n\t/// follows it instead of standing straight up.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintSweep( Vector2 c, Color color )\n\t{\n\t\t// Hub, radius and extent of the path. Everything else is derived from these, so the glyph\n\t\t// stays consistent with itself if the arc is retuned.\n\t\tconst float HubX = -7f;\n\t\tconst float HubY = -7.5f;\n\t\tconst float Radius = 12f;\n\n\t\t// Half the profile\u0027s width, so the band either side of the path IS the solid.\n\t\tconst float Half = 2.6f;\n\n\t\tconst float From = 0f;\n\t\tconst float To = 90f;\n\t\tconst float ArrowAt = To \u002B 10f;\n\n\t\tvar hub = At( c, HubX, HubY );\n\n\t\t// The swept solid: the path offset either side of itself. Faint rather than outlined at\n\t\t// full weight, so the path stays the strongest line in the glyph.\n\t\tStroked( color.WithAlpha( 0.32f ), 2f );\n\t\tArc( hub, Radius - Half, From, To, 18 );\n\t\tArc( hub, Radius \u002B Half, From, To, 18 );\n\n\t\tStroked( color, 1.7f );\n\t\tArc( hub, Radius, From, ArrowAt, 20 );\n\n\t\t// Where it starts, and where it arrives.\n\t\tSweepStation( hub, Radius, Half, From, color.WithAlpha( 0.55f ), 1.4f );\n\t\tSweepStation( hub, Radius, Half, To, color, 1.7f );\n\n\t\tvar end = ArrowAt * MathF.PI / 180f;\n\t\tvar tip = hub \u002B new Vector2( MathF.Cos( end ), MathF.Sin( end ) ) * Radius * _scale;\n\n\t\tArrowHead( tip, new Vector2( -MathF.Sin( end ), MathF.Cos( end ) ), color, 3.6f );\n\t}\n\n\t/// \u003Csummary\u003EThe profile at one station of a sweep: a diamond spanning the swept band, drawn\n\t/// ACROSS the path rather than lying flat, because a sweep takes its profile perpendicular to\n\t/// where it is going \u2014 see SweepFeature.\u003C/summary\u003E\n\tprivate static void SweepStation( Vector2 hub, float radius, float half, float degrees, Color color, float width )\n\t{\n\t\tvar angle = degrees * MathF.PI / 180f;\n\t\tvar radial = new Vector2( MathF.Cos( angle ), MathF.Sin( angle ) );\n\t\tvar tangent = new Vector2( -radial.y, radial.x );\n\t\tvar centre = hub \u002B radial * radius * _scale;\n\n\t\tStroked( color, width );\n\t\tOutline(\n\t\t\tcentre \u002B radial * half * _scale,\n\t\t\tcentre \u002B tangent * half * 0.62f * _scale,\n\t\t\tcentre - radial * half * _scale,\n\t\t\tcentre - tangent * half * 0.62f * _scale );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Two sections, and the skin ruled between them.\n\t///\n\t/// The sections are drawn as flat diamonds \u2014 the same plan-view profile Extrude and Sweep use,\n\t/// so a closed sketch reads the same way everywhere on this strip \u2014 one small and one large, so\n\t/// what lies between them has to be a loft rather than an extrusion. The sides are STRAIGHT,\n\t/// which is what the kernel actually does: neighbouring sections joined by a ruled surface, not\n\t/// a spline smoothly through them.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintLoft( Vector2 c, Color color )\n\t{\n\t\tconst float TopY = -7f;\n\t\tconst float TopHalf = 3.4f;\n\t\tconst float BottomY = 6.6f;\n\t\tconst float BottomHalf = 7.6f;\n\n\t\t// The skin, as a tint between the two sections \u2014 same weight as Chamfer and Shell, so it\n\t\t// reads as material rather than as two more lines.\n\t\tFilled( color.WithAlpha( 0.22f ) );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -TopHalf, TopY ), At( c, TopHalf, TopY ),\n\t\t\tAt( c, BottomHalf, BottomY ), At( c, -BottomHalf, BottomY ) );\n\n\t\tStroked( color, 1.7f );\n\t\tEditor.Paint.DrawLine( At( c, -TopHalf, TopY ), At( c, -BottomHalf, BottomY ) );\n\t\tEditor.Paint.DrawLine( At( c, TopHalf, TopY ), At( c, BottomHalf, BottomY ) );\n\n\t\tLoftSection( c, TopY, TopHalf, TopHalf * 0.46f, color );\n\t\tLoftSection( c, BottomY, BottomHalf, BottomHalf * 0.32f, color );\n\t}\n\n\t/// \u003Csummary\u003EOne loft section, in plan: a diamond as wide as the section and shallow enough to\n\t/// read as lying flat, rather than as the top and bottom edges of a trapezium.\u003C/summary\u003E\n\tprivate static void LoftSection( Vector2 c, float y, float half, float depth, Color color )\n\t{\n\t\tStroked( color, 1.5f );\n\t\tOutline( At( c, -half, y ), At( c, 0, y - depth ), At( c, half, y ), At( c, 0, y \u002B depth ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A solid block with its corner cut away, the cut face called out.\n\t///\n\t/// The old glyph was an outlined square with a small nick in one corner, which at toolbar size\n\t/// is a page icon and nothing else. Two changes fix it: fill the body, so it reads as a solid\n\t/// rather than as a sheet, and cut deep enough that the chamfer is a face rather than a nick.\n\t/// The faint lines show the corner that was removed.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintChamfer( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.22f ) );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -7, -1.5f ), At( c, -1.5f, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );\n\n\t\tStroked( color, 1.5f );\n\t\tOutline( At( c, -7, -1.5f ), At( c, -1.5f, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );\n\n\t\t// The cut face, in the same amber the sketch pencil draws with \u2014 the accent on this strip\n\t\t// means \u0022the thing this operation did\u0022.\n\t\tStroked( ClickColor, 2.8f );\n\t\tEditor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -1.5f, -7 ) );\n\n\t\tStroked( color.WithAlpha( 0.3f ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -7, -7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, -7 ), At( c, -1.5f, -7 ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// The chamfer\u0027s twin, and deliberately so: the same solid, the same corner gone, the same\n\t/// ghost of the corner that was removed \u2014 the ONLY difference is that the accent is an arc\n\t/// instead of a straight line.\n\t///\n\t/// That is the whole point. These two sit next to each other on the strip and the thing a\n\t/// person needs to tell apart at 40px is round versus flat, which a shared body makes obvious\n\t/// and two unrelated drawings would bury.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintFillet( Vector2 c, Color color )\n\t{\n\t\t// The arc\u0027s centre is the inner corner of the cut, so it runs from (-7,-1.5) to (-1.5,-7)\n\t\t// exactly where the chamfer\u0027s straight cut does.\n\t\tvar arc = ArcPoints( At( c, -1.5f, -1.5f ), 5.5f, 180f, 270f, 10 );\n\n\t\tvar body = new List\u003CVector2\u003E( arc );\n\t\tbody.Add( At( c, 7, -7 ) );\n\t\tbody.Add( At( c, 7, 7 ) );\n\t\tbody.Add( At( c, -7, 7 ) );\n\n\t\tFilled( color.WithAlpha( 0.22f ) );\n\t\tEditor.Paint.DrawPolygon( body.ToArray() );\n\n\t\tStroked( color, 1.5f );\n\t\tOutline( body.ToArray() );\n\n\t\tStroked( ClickColor, 2.8f );\n\t\tArc( At( c, -1.5f, -1.5f ), 5.5f, 180f, 270f, 10 );\n\n\t\tStroked( color.WithAlpha( 0.3f ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, -7, -1.5f ), At( c, -7, -7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, -7 ), At( c, -1.5f, -7 ) );\n\t}\n\n\t/// \u003Csummary\u003EThe points Arc walks, for a glyph that needs the arc as part of a filled outline\n\t/// rather than as a stroke. Same maths, so the fill and the stroke cannot drift apart.\u003C/summary\u003E\n\tprivate static List\u003CVector2\u003E ArcPoints( Vector2 center, float radius,\n\t\tfloat fromDegrees, float toDegrees, int segments )\n\t{\n\t\tvar points = new List\u003CVector2\u003E( segments \u002B 1 );\n\n\t\tfor ( var i = 0; i \u003C= segments; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = fromDegrees \u002B (toDegrees - fromDegrees) * (i / (float)segments);\n\t\t\tvar radians = t * MathF.PI / 180f;\n\n\t\t\tpoints.Add( center \u002B new Vector2( MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) * _scale );\n\t\t}\n\n\t\treturn points;\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A hollowed solid in section: material on three sides, opening at the top.\n\t///\n\t/// A square inside a square is a frame, a border, a picture \u2014 it was never going to say\n\t/// \u0022hollowed to a wall thickness\u0022. THE WALL IS THE OBJECT, so the wall is what gets filled and\n\t/// the void is what gets left out, which is how a section drawing says it.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintShell( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.9f ) );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -7.8f, -7f ), At( c, -3.6f, -7f ), At( c, -3.6f, 3.2f ),\n\t\t\tAt( c, 3.6f, 3.2f ), At( c, 3.6f, -7f ), At( c, 7.8f, -7f ),\n\t\t\tAt( c, 7.8f, 7.4f ), At( c, -7.8f, 7.4f ) );\n\n\t\t// The opening, as a faint lid line, so the U reads as a container rather than as a letter.\n\t\tStroked( color.WithAlpha( 0.45f ), 1.1f );\n\t\tEditor.Paint.DrawLine( At( c, -3.6f, -7f ), At( c, 3.6f, -7f ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A quad split into four, with one of those four split again \u2014 subdivision, drawn literally.\n\t///\n\t/// The old glyph was a rounded square with a cross and a dot in the middle, which is the\n\t/// universal \u0022add\u0022 icon and was read as one. Showing one quadrant DENSER than its neighbours is\n\t/// what the operation actually does, and the density is carried by a tint as well as by lines so\n\t/// it survives being small \u2014 at twenty-four pixels a 4x4 of hairlines is a grey smear.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintSubdivide( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.6f );\n\t\tOutline( At( c, -8, -8 ), At( c, 8, -8 ), At( c, 8, 8 ), At( c, -8, 8 ) );\n\n\t\tFilled( color.WithAlpha( 0.3f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -8, -8 ), At( c, 0, -8 ), At( c, 0, 0 ), At( c, -8, 0 ) );\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.5f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -8 ), At( c, 0, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, -8, 0 ), At( c, 8, 0 ) );\n\n\t\tStroked( color.WithAlpha( 0.85f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, -4, -8 ), At( c, -4, 0 ) );\n\t\tEditor.Paint.DrawLine( At( c, -8, -4 ), At( c, 0, -4 ) );\n\t}\n\n\t/// \u003Csummary\u003EA solid shape and its reflection across a dashed mirror line.\u003C/summary\u003E\n\tprivate static void PaintMirror( Vector2 c, Color color )\n\t{\n\t\t// Mirror plane, dashed.\n\t\tStroked( color.WithAlpha( 0.5f ), 1.2f );\n\t\tfor ( var y = -8f; y \u003C 8f; y \u002B= 3.6f )\n\t\t\tEditor.Paint.DrawLine( At( c, 0, y ), At( c, 0, y \u002B 2.1f ) );\n\n\t\t// Source: solid.\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon( At( c, -2.4f, -5.6f ), At( c, -8, 0 ), At( c, -2.4f, 5.6f ) );\n\n\t\t// Reflection: outlined, so the two are not mistaken for a pattern.\n\t\tStroked( color );\n\t\tOutline( At( c, 2.4f, -5.6f ), At( c, 8, 0 ), At( c, 2.4f, 5.6f ) );\n\t}\n\n\t/// \u003Csummary\u003EOne body copied along a direction \u2014 first solid, copies outlined and fading.\u003C/summary\u003E\n\tprivate static void PaintLinearPattern( Vector2 c, Color color )\n\t{\n\t\t// TWO FAULTS, AND THE SECOND IS THE ONE THAT MATTERED. Three 6-unit squares starting at\n\t\t// -8.4 ran to \u002B12, so the glyph was 20.4 units wide in a box of 18 - and, worse, its centre\n\t\t// of mass sat 1.8 units RIGHT of the button\u0027s centre, because the run was never balanced\n\t\t// about it. An off-centre glyph in a row of centred ones is visible long before an\n\t\t// oversized one is, and neither is visible while looking at this icon on its own.\n\t\t//\n\t\t// Three 5.2 squares with 1.2 between them is 18 exactly, laid out symmetrically about c.\n\t\tconst float Square = 5.2f;\n\t\tconst float Gap = 1.2f;\n\t\tconst float First = -9f;\n\n\t\tFilled( color );\n\t\tEditor.Paint.DrawRect( Box( c, First, -Square / 2f, Square, Square ), 1.2f * _scale );\n\n\t\tStroked( color.WithAlpha( 0.8f ) );\n\t\tEditor.Paint.DrawRect( Box( c, First \u002B Square \u002B Gap, -Square / 2f, Square, Square ), 1.2f * _scale );\n\n\t\tStroked( color.WithAlpha( 0.45f ) );\n\t\tEditor.Paint.DrawRect( Box( c, First \u002B 2f * (Square \u002B Gap), -Square / 2f, Square, Square ), 1.2f * _scale );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Copies stepped around an axis, on a ring that can actually be seen.\n\t///\n\t/// The old glyph drew the ring as twelve dashes, and at toolbar size twelve dashes are a faint\n\t/// smudge \u2014 which left three small squares floating with nothing to explain them. A solid thin\n\t/// ring and a dot at the centre cost less ink and say more, and the copies are smaller than they\n\t/// were so they sit ON the ring instead of swallowing it.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintCircularPattern( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.7f ), 1.4f );\n\t\tArc( c, 6.6f, 0f, 360f, 40 );\n\n\t\tFilled( color.WithAlpha( 0.8f ) );\n\t\tEditor.Paint.DrawRect( Box( c, -1.3f, -1.3f, 2.6f, 2.6f ), 1.3f * _scale );\n\n\t\tvar angles = new[] { -90f, 30f, 150f };\n\n\t\tfor ( var i = 0; i \u003C angles.Length; i\u002B\u002B )\n\t\t{\n\t\t\tvar radians = angles[i] * MathF.PI / 180f;\n\n\t\t\tvar box = Box( c,\n\t\t\t\tMathF.Cos( radians ) * 6.6f - 2.5f,\n\t\t\t\tMathF.Sin( radians ) * 6.6f - 2.5f, 5f, 5f );\n\n\t\t\t// One filled, the rest outlined \u2014 the same \u0022this is the original, these are the copies\u0022\n\t\t\t// grammar the linear pattern uses, so the pair read as a family.\n\t\t\tif ( i == 0 )\n\t\t\t{\n\t\t\t\tFilled( color );\n\t\t\t\tEditor.Paint.DrawRect( box, 1f * _scale );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tStroked( color.WithAlpha( 0.9f ), 1.5f );\n\t\t\t\tEditor.Paint.DrawRect( box, 1f * _scale );\n\t\t\t}\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EMove/rotate/scale \u2014 a body with four-way translation arrows through it.\u003C/summary\u003E\n\tprivate static void PaintTransform( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.6f ) );\n\t\tOutline( At( c, -4, -4 ), At( c, 4, -4 ), At( c, 4, 4 ), At( c, -4, 4 ) );\n\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -5.4f ), At( c, 0, 5.4f ) );\n\t\tEditor.Paint.DrawLine( At( c, -5.4f, 0 ), At( c, 5.4f, 0 ) );\n\n\t\tArrowHead( At( c, 0, -8.2f ), new Vector2( 0, -1 ), color, 3f );\n\t\tArrowHead( At( c, 0, 8.2f ), new Vector2( 0, 1 ), color, 3f );\n\t\tArrowHead( At( c, -8.2f, 0 ), new Vector2( -1, 0 ), color, 3f );\n\t\tArrowHead( At( c, 8.2f, 0 ), new Vector2( 1, 0 ), color, 3f );\n\t}\n\n\t/// \u003Csummary\u003EA UV grid with one texel lit, and the projection arriving from off-surface.\u003C/summary\u003E\n\tprivate static void PaintUVProject( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tOutline( At( c, -7, -2 ), At( c, 7, -2 ), At( c, 7, 8 ), At( c, -7, 8 ) );\n\n\t\tStroked( color.WithAlpha( 0.6f ), 1.1f );\n\t\tEditor.Paint.DrawLine( At( c, -2.4f, -2 ), At( c, -2.4f, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.4f, -2 ), At( c, 2.4f, 8 ) );\n\t\tEditor.Paint.DrawLine( At( c, -7, 3 ), At( c, 7, 3 ) );\n\n\t\t// One lit texel, so the grid reads as a texture rather than a wireframe.\n\t\tFilled( color.WithAlpha( 0.8f ) );\n\t\tEditor.Paint.DrawRect( Box( c, -6.3f, -1.3f, 3.8f, 3.6f ), 0.6f * _scale );\n\n\t\t// The projection coming down onto it.\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -8.4f ), At( c, 0, -4.4f ) );\n\t\tArrowHead( At( c, 0, -2.6f ), new Vector2( 0, 1 ), color, 3f );\n\t}\n\n\t/// \u003Csummary\u003EA cube with ONE of its three visible faces filled \u2014 the operation is \u0022this face,\n\t/// not that one\u0022, so what the glyph has to show is faces being told apart. A paint pot or a\n\t/// swatch would say \u0022material\u0022 without saying \u0022per face\u0022, which is the whole distinction.\u003C/summary\u003E\n\tprivate static void PaintFaceMaterial( Vector2 c, Color color )\n\t{\n\t\t// Isometric cube: top rhombus, then the two visible side quads.\n\t\tvar top = At( c, 0, -8 );\n\t\tvar right = At( c, 8, -3.5f );\n\t\tvar bottom = At( c, 0, 1 );\n\t\tvar left = At( c, -8, -3.5f );\n\n\t\t// The lit face, filled. DrawPolygon fills, which is exactly what is wanted here and is why\n\t\t// the other faces are walked as lines instead.\n\t\tFilled( color.WithAlpha( 0.85f ) );\n\t\tEditor.Paint.DrawPolygon( top, right, bottom, left );\n\n\t\tStroked( color );\n\t\tOutline( top, right, bottom, left );\n\n\t\t// The two side faces, left plain so the filled top reads as the odd one out.\n\t\tvar lowLeft = At( c, -8, 5.5f );\n\t\tvar lowMid = At( c, 0, 10 );\n\t\tvar lowRight = At( c, 8, 5.5f );\n\n\t\tOutline( left, bottom, lowMid, lowLeft );\n\t\tOutline( bottom, right, lowRight, lowMid );\n\t}\n\n\t// --- sketch tools ---------------------------------------------------------------------------\n\t//\n\t// One rule for the whole row: SHOW THE SHAPE THE TOOL MAKES, AND SHOW HOW IT IS PLACED.\n\t//\n\t// The second half is what earns its keep. Every family behind a chevron draws the identical\n\t// shape and differs only in which points you click \u2014 a corner rectangle and a centre rectangle\n\t// are the same rectangle \u2014 so the shape alone cannot tell them apart. The shape is the body of\n\t// the glyph and the click points are accent dots on it, which makes the pair legible side by\n\t// side without either needing a label.\n\t//\n\t// The dots are annotation and must never outweigh the shape. They were half again this size to\n\t// begin with, which looked right on a large preview and swallowed the geometry at the size these\n\t// are actually seen at.\n\n\t/// \u003Csummary\u003EThe colour of a click point. Deliberately the one warm accent in a monochrome row,\n\t/// so \u0022this is where you press\u0022 reads before anything else does.\u003C/summary\u003E\n\tprivate static readonly Color ClickColor = new( 1f, 0.77f, 0.24f, 1f );\n\n\t/// \u003Csummary\u003EAn end that does not join up. Warm rather than red \u2014 this is information, not an\n\t/// error, and a sketch mid-draw is full of them.\u003C/summary\u003E\n\tprivate static readonly Color LooseEndColor = new( 1f, 0.48f, 0.36f, 1f );\n\n\t/// \u003Csummary\u003EA guide line \u2014 a radius, a diagonal, a centre line. Something the tool uses to place\n\t/// the shape rather than part of the shape itself.\u003C/summary\u003E\n\tprivate static Color GuideColor( Color color ) =\u003E color.WithAlpha( 0.35f );\n\n\t/// \u003Csummary\u003EA filled dot in the nominal icon space. DrawRect with a corner radius of half its\n\t/// own size, since Paint has no circle of its own and this is exact.\u003C/summary\u003E\n\tprivate static void Dot( Vector2 center, float radius, Color color )\n\t{\n\t\tFilled( color );\n\t\tEditor.Paint.DrawRect( Box( center, -radius, -radius, radius * 2f, radius * 2f ), radius * _scale );\n\t}\n\n\tprivate static void ClickDot( Vector2 p, float radius = 1.8f ) =\u003E Dot( p, radius, ClickColor );\n\n\t/// \u003Csummary\u003EThe corners of a regular hexagon, for the two polygon tools.\u003C/summary\u003E\n\tprivate static Vector2[] Hexagon( Vector2 c, float radius, float rotationDegrees )\n\t{\n\t\tvar points = new Vector2[6];\n\n\t\tfor ( var i = 0; i \u003C 6; i\u002B\u002B )\n\t\t{\n\t\t\tvar a = (rotationDegrees \u002B i * 60f) * MathF.PI / 180f;\n\t\t\tpoints[i] = At( c, MathF.Cos( a ) * radius, MathF.Sin( a ) * radius );\n\t\t}\n\n\t\treturn points;\n\t}\n\n\t/// \u003Csummary\u003EA cursor with a point caught under it. Select drags sketch POINTS, which is what the\n\t/// dot says and a bare arrow would not.\u003C/summary\u003E\n\tprivate static void PaintSelectTool( Vector2 c, Color color )\n\t{\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -4, -8 ), At( c, -4, 4 ), At( c, -1, 1 ), At( c, 1.5f, 6 ),\n\t\t\tAt( c, 4, 5 ), At( c, 1.5f, 0.2f ), At( c, 5, -0.5f ) );\n\n\t\tClickDot( At( c, 5, 5 ), 2.2f );\n\t}\n\n\tprivate static void PaintLineTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, 6 ), At( c, 6.5f, -6 ) );\n\n\t\tClickDot( At( c, -6.5f, 6 ) );\n\t\tClickDot( At( c, 6.5f, -6 ) );\n\t}\n\n\t/// \u003Csummary\u003EThe same line, marked at its MIDDLE - with the tick mark that means midpoint in every\n\t/// CAD package there is. One click dot, because the second click is an end and the far one comes\n\t/// for free.\u003C/summary\u003E\n\tprivate static void PaintLineMidpointTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, 6 ), At( c, 6.5f, -6 ) );\n\n\t\t// Across the line rather than along it, so it reads as a mark ON the line instead of a second\n\t\t// shorter line beside it.\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, -2.1f, -2.3f ), At( c, 2.1f, 2.3f ) );\n\n\t\tClickDot( c, 1.9f );\n\t\tClickDot( At( c, 6.5f, -6 ) );\n\t}\n\n\t/// \u003Csummary\u003ETwo opposite corners marked: click one, then the other.\u003C/summary\u003E\n\tprivate static void PaintRectangleTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tOutline( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );\n\n\t\tClickDot( At( c, -6.5f, -5 ) );\n\t\tClickDot( At( c, 6.5f, 5 ) );\n\t}\n\n\t/// \u003Csummary\u003EThe same rectangle, marked at its CENTRE instead \u2014 with the half-diagonal it is\n\t/// dragged out along.\u003C/summary\u003E\n\tprivate static void PaintRectangleCentreTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tOutline( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );\n\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( c, At( c, 6.5f, 5 ) );\n\n\t\tClickDot( c, 1.9f );\n\t}\n\n\tprivate static void PaintCircleTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tArc( c, 6.2f, 0, 360, 28 );\n\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( c, At( c, 6.2f, 0 ) );\n\n\t\tClickDot( c, 1.9f );\n\t}\n\n\t/// \u003Csummary\u003EThe same circle with three points ON the rim and no centre \u2014 which is precisely the\n\t/// difference between the two ways of placing it.\u003C/summary\u003E\n\tprivate static void PaintCircleThreePointTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color );\n\t\tArc( c, 6.2f, 0, 360, 28 );\n\n\t\tforeach ( var degrees in new[] { -90f, 30f, 150f } )\n\t\t{\n\t\t\tvar a = degrees * MathF.PI / 180f;\n\t\t\tClickDot( At( c, MathF.Cos( a ) * 6.2f, MathF.Sin( a ) * 6.2f ) );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// An arc standing on its centre, with both radii drawn.\n\t///\n\t/// It was drawn small and off to one side first, and at the size these are actually used it read\n\t/// as a tick mark rather than a curve. An arc has to span the box to look like an arc.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintArcTool( Vector2 c, Color color )\n\t{\n\t\t// RADIUS 9, NOT 10.5, AND THE HUB SITS AT 4.2 RATHER THAN 5.5. A radius of 10.5 put the\n\t\t// guide rails 21 units apart inside a box this file says is 18, which made this the second\n\t\t// widest glyph on the strip - and nothing about one icon in isolation shows that. Nine\n\t\t// fills the width exactly, and dropping the hub centres the drawing\u0027s own height in the\n\t\t// button instead of hanging it below the middle.\n\t\tvar hub = At( c, 0, 4.2f );\n\n\t\tStroked( color, 1.9f );\n\t\tArc( hub, 9f, 180, 360, 20 );\n\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( hub, At( c, -9f, 4.2f ) );\n\t\tEditor.Paint.DrawLine( hub, At( c, 9f, 4.2f ) );\n\n\t\tClickDot( hub, 1.9f );\n\t}\n\n\t/// \u003Csummary\u003EThe same arc with no centre at all, marked instead at both ends and the point it\n\t/// passes through.\u003C/summary\u003E\n\tprivate static void PaintArcThreePointTool( Vector2 c, Color color )\n\t{\n\t\t// A SMALLER ARC THAN PaintArcTool\u0027s, ON PURPOSE. This was the widest glyph on the strip by\n\t\t// a distance - 24.6 units against a nominal 18 - and the arc was not what made it so: the\n\t\t// three dots are the whole point of the tool, they sit ON the arc\u0027s ends, and a 1.8 dot at\n\t\t// x = 10.5 reaches 12.3. The dots are part of the drawing, so the arc gives up the room for\n\t\t// them rather than the pair of icons disagreeing about how wide an arc glyph is: both now\n\t\t// fill exactly 18 units, which is the measurement the eye actually compares.\n\t\tvar hub = At( c, 0, 4.2f );\n\n\t\tStroked( color, 1.9f );\n\t\tArc( hub, 7.2f, 180, 360, 20 );\n\n\t\tClickDot( At( c, -7.2f, 4.2f ) );\n\t\tClickDot( At( c, 0, -3f ) );\n\t\tClickDot( At( c, 7.2f, 4.2f ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A polygon with its corners ON the circle.\n\t///\n\t/// The circle is drawn brighter than a guide normally would be, because where it sits relative\n\t/// to the polygon IS the whole difference between this and the circumscribed version. Draw it at\n\t/// guide strength and the two glyphs become the same hexagon.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintPolygonTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.6f ), 1.2f );\n\t\tArc( c, 7f, 0, 360, 28 );\n\n\t\tStroked( color, 1.7f );\n\t\tOutline( Hexagon( c, 7f, -90f ) );\n\n\t\tClickDot( c, 1.7f );\n\t}\n\n\t/// \u003Csummary\u003EEdges on the circle instead, so it sits visibly inside the polygon \u2014 the apothem is\n\t/// 0.866 of the radius, a gap wide enough to read small.\u003C/summary\u003E\n\tprivate static void PaintPolygonCircumscribedTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\t\tOutline( Hexagon( c, 7.6f, -90f ) );\n\n\t\tStroked( color.WithAlpha( 0.6f ), 1.2f );\n\t\tArc( c, 6.6f, 0, 360, 28 );\n\n\t\tClickDot( c, 1.7f );\n\t}\n\n\t/// \u003Csummary\u003EA slot, with the centre line you actually click marked at both ends.\u003C/summary\u003E\n\tprivate static void PaintSlotTool( Vector2 c, Color color )\n\t{\n\t\tconst float r = 4.6f;\n\n\t\tStroked( color );\n\t\tEditor.Paint.DrawLine( At( c, -3, -r ), At( c, 3, -r ) );\n\t\tEditor.Paint.DrawLine( At( c, -3, r ), At( c, 3, r ) );\n\t\tArc( At( c, 3, 0 ), r, -90, 90, 12 );\n\t\tArc( At( c, -3, 0 ), r, 90, 270, 12 );\n\n\t\tStroked( GuideColor( color ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, -3, 0 ), At( c, 3, 0 ) );\n\n\t\tClickDot( At( c, -3, 0 ) );\n\t\tClickDot( At( c, 3, 0 ) );\n\t}\n\n\t/// \u003Csummary\u003ECrosshairs around a point. The gap at the centre is what stops it reading as a plus\n\t/// sign.\u003C/summary\u003E\n\tprivate static void PaintPointTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -7, 0 ), At( c, -2.5f, 0 ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.5f, 0 ), At( c, 7, 0 ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, -7 ), At( c, 0, -2.5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0, 2.5f ), At( c, 0, 7 ) );\n\n\t\tClickDot( c, 2.2f );\n\t}\n\n\t/// \u003Csummary\u003EA dashed line: geometry that guides and never becomes part of a profile. Dashed\n\t/// because that is how construction geometry is drawn in the viewport, so the button and the\n\t/// thing it makes look like each other.\u003C/summary\u003E\n\tprivate static void PaintConstructionTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\n\t\tforeach ( var (from, to) in new[] { (0f, 0.22f), (0.39f, 0.61f), (0.78f, 1f) } )\n\t\t{\n\t\t\tEditor.Paint.DrawLine(\n\t\t\t\tAt( c, -7 \u002B 14 * from, 6 - 12 * from ),\n\t\t\t\tAt( c, -7 \u002B 14 * to, 6 - 12 * to ) );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A shaded region with a gap in its outline, and the two loose ends called out.\n\t///\n\t/// This is exactly what the inspector shows: which regions closed, and where a chain did not.\n\t/// Drawn first as a stub with a dot on it, which at the size these are seen reads as a box with\n\t/// a speck in the corner and says nothing. The fill has to be solid enough to read as shading and\n\t/// the gap has to be a real hole in the outline.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintProfileInspectorTool( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.41f ) );\n\t\tEditor.Paint.DrawPolygon( At( c, -6.5f, -5 ), At( c, 6.5f, -5 ), At( c, 6.5f, 5 ), At( c, -6.5f, 5 ) );\n\n\t\t// Walked as an open polyline rather than an outline, because the gap is the point.\n\t\tStroked( color, 1.7f );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, -1.6f ), At( c, 6.5f, -5 ) );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, -5 ), At( c, -6.5f, -5 ) );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, -5 ), At( c, -6.5f, 5 ) );\n\t\tEditor.Paint.DrawLine( At( c, -6.5f, 5 ), At( c, 6.5f, 5 ) );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, 5 ), At( c, 6.5f, 1.6f ) );\n\n\t\tDot( At( c, 6.5f, -1.6f ), 1.9f, LooseEndColor );\n\t\tDot( At( c, 6.5f, 1.6f ), 1.9f, LooseEndColor );\n\t}\n\n\t/// \u003Csummary\u003EA plain tick. The one glyph in the row that must not be clever: it ends the mode,\n\t/// and the confirm colour it is painted in already carries the meaning.\u003C/summary\u003E\n\tprivate static void PaintFinishSketchTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 2.2f );\n\t\tEditor.Paint.DrawLine( At( c, -6, 0.5f ), At( c, -1.5f, 5 ) );\n\t\tEditor.Paint.DrawLine( At( c, -1.5f, 5 ), At( c, 6.5f, -5 ) );\n\t}\n\n\t// --- sculpt tools ---------------------------------------------------------------------------\n\t//\n\t// EVERY ONE OF THESE IS A SURFACE AND WHAT HAPPENS TO IT. The obvious way to draw six brushes is\n\t// six brush heads with a small badge each, which at 27px is six identical blobs. Drawing the\n\t// EFFECT instead means the row can be read at a glance without learning it: a bump rising, a\n\t// ripple flattening, a peak dragged sideways.\n\t//\n\t// The surface runs across the lower half so every glyph shares a baseline and the row reads as\n\t// one family.\n\n\t/// \u003Csummary\u003EA surface with a bump pushed up out of it, and the brush\u0027s ring resting on the\n\t/// bump. The feature-strip glyph, so it says \u0022sculpting\u0022 rather than any one brush.\u003C/summary\u003E\n\tprivate static void PaintSculpt( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\t\tSurfaceWithBump( c, 4.5f );\n\n\t\t// The brush ring, seen at a slight angle so it reads as sitting ON the surface.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.3f );\n\t\tArc( At( c, 0, -3.4f ), 5.6f, 0f, 360f, 20 );\n\t}\n\n\t/// \u003Csummary\u003EA bump, and an arrow pushing outward from it: draw adds material along the normal.\u003C/summary\u003E\n\tprivate static void PaintSculptDraw( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\t\tSurfaceWithBump( c, 4f );\n\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -1.5f ), At( c, 0, -7f ) );\n\t\tArrowHead( At( c, 0, -8f ), new Vector2( 0, -1 ), color );\n\t}\n\n\t/// \u003Csummary\u003EA ripple above, the same surface calmed below. Smooth is the one brush whose whole\n\t/// meaning is the difference between two lines.\u003C/summary\u003E\n\tprivate static void PaintSculptSmooth( Vector2 c, Color color )\n\t{\n\t\t// Rippled.\n\t\tStroked( color.WithAlpha( 0.85f ), 1.5f );\n\t\tvar previous = At( c, -8.5f, -4f );\n\n\t\tfor ( var i = 1; i \u003C= 24; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = i / 24f;\n\t\t\tvar x = -8.5f \u002B 17f * t;\n\t\t\tvar y = -4f \u002B MathF.Sin( t * MathF.PI * 3f ) * 2.6f;\n\t\t\tvar point = At( c, x, y );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\n\t\t// Calmed.\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, 5f ), At( c, 8.5f, 5f ) );\n\n\t\tStroked( color.WithAlpha( 0.6f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -0.5f ), At( c, 0, 2.2f ) );\n\t\tArrowHead( At( c, 0, 3.4f ), new Vector2( 0, 1 ), color.WithAlpha( 0.6f ), 2.8f );\n\t}\n\n\t/// \u003Csummary\u003EA closed shape with arrows pushing out all round it \u2014 inflate acts everywhere at\n\t/// once, which is what tells it apart from draw.\u003C/summary\u003E\n\tprivate static void PaintSculptInflate( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\t\tArc( c, 4.6f, 0f, 360f, 20 );\n\n\t\tfor ( var i = 0; i \u003C 4; i\u002B\u002B )\n\t\t{\n\t\t\tvar radians = (45f \u002B i * 90f) * MathF.PI / 180f;\n\t\t\tvar dir = new Vector2( MathF.Cos( radians ), MathF.Sin( radians ) );\n\n\t\t\tStroked( color.WithAlpha( 0.9f ), 1.3f );\n\t\t\tEditor.Paint.DrawLine( c \u002B dir * 5.8f * _scale, c \u002B dir * 8f * _scale );\n\t\t\tArrowHead( c \u002B dir * 9.2f * _scale, dir, color, 2.8f );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EA surface dragged sideways into a lean, with the pull shown as an arrow. Grab moves\n\t/// what it holds rather than adding to it, so nothing here points along the normal.\u003C/summary\u003E\n\tprivate static void PaintSculptGrab( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\n\t\t// A peak that leans right, rather than a symmetric bump.\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, 5f ), At( c, -2.5f, 5f ) );\n\t\tEditor.Paint.DrawLine( At( c, -2.5f, 5f ), At( c, 2.5f, -3f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.5f, -3f ), At( c, 5.5f, 5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 5.5f, 5f ), At( c, 8.5f, 5f ) );\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -2f, -6f ), At( c, 3.5f, -6f ) );\n\t\tArrowHead( At( c, 5f, -6f ), new Vector2( 1, 0 ), color );\n\t}\n\n\t/// \u003Csummary\u003EA bump with a straight edge laid across it \u2014 flatten is a plane meeting a surface.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintSculptFlatten( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.65f ), 1.5f );\n\t\tSurfaceWithBump( c, 5.5f );\n\n\t\t// The plane it is being cut back to.\n\t\tStroked( color, 2f );\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, -2.5f ), At( c, 8.5f, -2.5f ) );\n\t}\n\n\t/// \u003Csummary\u003ETwo arrows squeezing towards one ridge. Pinch gathers a surface rather than moving\n\t/// it, so both arrows point inward at the same line.\u003C/summary\u003E\n\tprivate static void PaintSculptPinch( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -7.5f ), At( c, 0, 7.5f ) );\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -8f, 0 ), At( c, -3.5f, 0 ) );\n\t\tArrowHead( At( c, -2.2f, 0 ), new Vector2( 1, 0 ), color );\n\n\t\tEditor.Paint.DrawLine( At( c, 8f, 0 ), At( c, 3.5f, 0 ) );\n\t\tArrowHead( At( c, 2.2f, 0 ), new Vector2( -1, 0 ), color );\n\t}\n\n\t/// \u003Csummary\u003EA patch of the surface hatched off. Masking protects rather than shapes, so this is\n\t/// the one sculpt glyph that is not a deformation.\u003C/summary\u003E\n\tprivate static void PaintSculptMask( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.85f ), 1.5f );\n\t\tOutline( At( c, -8, -6.5f ), At( c, 8, -6.5f ), At( c, 8, 6.5f ), At( c, -8, 6.5f ) );\n\n\t\t// Hatching, the universal \u0022held back\u0022 texture.\n\t\tStroked( color.WithAlpha( 0.7f ), 1.2f );\n\n\t\tfor ( var x = -6f; x \u003C= 8f; x \u002B= 3.4f )\n\t\t{\n\t\t\tvar top = MathF.Max( x - 13f, -8f );\n\t\t\tvar bottom = MathF.Min( x, 8f );\n\n\t\t\tEditor.Paint.DrawLine( At( c, bottom, -6.5f ), At( c, top, 6.5f ) );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EA coarse grid with a chevron down: fewer, bigger faces.\u003C/summary\u003E\n\tprivate static void PaintSculptLevelDown( Vector2 c, Color color ) =\u003E PaintSculptLevel( c, color, 2, down: true );\n\n\t/// \u003Csummary\u003EA fine grid with a chevron up: four times the faces, which is the whole cost.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintSculptLevelUp( Vector2 c, Color color ) =\u003E PaintSculptLevel( c, color, 4, down: false );\n\n\tprivate static void PaintSculptLevel( Vector2 c, Color color, int divisions, bool down )\n\t{\n\t\tconst float Half = 6.5f;\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.4f );\n\t\tOutline( At( c, -Half, -Half - 1.5f ), At( c, Half, -Half - 1.5f ),\n\t\t\tAt( c, Half, Half - 1.5f ), At( c, -Half, Half - 1.5f ) );\n\n\t\tStroked( color.WithAlpha( 0.65f ), 1f );\n\n\t\tfor ( var i = 1; i \u003C divisions; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = -Half \u002B i * (Half * 2f / divisions);\n\n\t\t\tEditor.Paint.DrawLine( At( c, t, -Half - 1.5f ), At( c, t, Half - 1.5f ) );\n\t\t\tEditor.Paint.DrawLine( At( c, -Half, t - 1.5f ), At( c, Half, t - 1.5f ) );\n\t\t}\n\n\t\t// The chevron, below the grid so the two never overlap at strip size.\n\t\tStroked( color, 1.8f );\n\n\t\tif ( down )\n\t\t{\n\t\t\tEditor.Paint.DrawLine( At( c, -3.5f, 6f ), At( c, 0, 9f ) );\n\t\t\tEditor.Paint.DrawLine( At( c, 0, 9f ), At( c, 3.5f, 6f ) );\n\t\t}\n\t\telse\n\t\t{\n\t\t\tEditor.Paint.DrawLine( At( c, -3.5f, 9f ), At( c, 0, 6f ) );\n\t\t\tEditor.Paint.DrawLine( At( c, 0, 6f ), At( c, 3.5f, 9f ) );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EA dense surface collapsing into a flat square: the sculpt becoming a texture, which\n\t/// is the whole point of the pipeline and the one operation here that produces a file.\u003C/summary\u003E\n\t/// \u003Csummary\u003E\n\t/// A grease pencil laid over a wavy scribble.\n\t///\n\t/// NOT THE SKETCH PENCIL, which this sits two buttons away from and must not be mistaken for.\n\t/// That one is a sharp #2 drawing a straight line and it makes geometry; this is a fat blunt\n\t/// marker over a loose squiggle, and the squiggle is doing the work \u2014 a scribble is what\n\t/// handwriting looks like at 18px, and nothing that produces a solid in this bar is drawn\n\t/// scribbly.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintNoteTool( Vector2 c, Color color )\n\t{\n\t\t// The scribble first, so the marker sits on top of it the way a pen sits on its own line.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.5f );\n\n\t\tvar previous = At( c, -8f, 5.5f );\n\n\t\tfor ( var i = 1; i \u003C= 20; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = i / 20f;\n\t\t\tvar point = At( c, -8f \u002B 13f * t, 5.5f \u002B MathF.Sin( t * MathF.PI * 2.2f ) * 1.8f );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\n\t\t// The barrel, drawn as a slab on the diagonal rather than the sketch pencil\u0027s thin shaft.\n\t\tvar tip = At( c, -5.5f, 1.5f );\n\t\tvar along = (At( c, 7f, -7.5f ) - tip).Normal;\n\t\tvar across = new Vector2( -along.y, along.x ) * (2.6f * _scale);\n\n\t\tStroked( color, 1.5f );\n\t\tOutline(\n\t\t\ttip \u002B across * 0.35f,\n\t\t\tAt( c, 7f, -7.5f ) \u002B across,\n\t\t\tAt( c, 8.5f, -8.5f ) \u002B across,\n\t\t\tAt( c, 8.5f, -8.5f ) - across,\n\t\t\tAt( c, 7f, -7.5f ) - across,\n\t\t\ttip - across * 0.35f );\n\n\t\t// The nib, filled: the one part of a marker that is a different colour from the barrel, and\n\t\t// what makes the shape read as pointing at the scribble rather than away from it.\n\t\tFilled( color );\n\t\tEditor.Paint.DrawPolygon( tip, At( c, -3.2f, -0.4f ) \u002B across * 0.75f, At( c, -3.2f, -0.4f ) - across * 0.75f );\n\t}\n\n\t/// \u003Csummary\u003EAn eraser on the same scribble, taking a bite out of it. The gap in the line is the\n\t/// whole glyph \u2014 an eraser drawn hovering over an intact scribble is just a second block\n\t/// shape.\u003C/summary\u003E\n\tprivate static void PaintNoteEraseTool( Vector2 c, Color color )\n\t{\n\t\t// Left half of the scribble survives; the right half is where the eraser has been.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.5f );\n\n\t\tvar previous = At( c, -8.5f, 5.5f );\n\n\t\tfor ( var i = 1; i \u003C= 10; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = i / 20f;\n\t\t\tvar point = At( c, -8.5f \u002B 13f * t, 5.5f \u002B MathF.Sin( t * MathF.PI * 2.2f ) * 1.8f );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\n\t\t// The block, tilted so it reads as being pushed along the line rather than parked on it.\n\t\tvar down = new Vector2( 0.34f, 0.94f );\n\t\tvar side = new Vector2( -down.y, down.x );\n\t\tvar centre = At( c, 3f, -1f );\n\t\tvar half = 4.6f * _scale;\n\t\tvar length = 5.6f * _scale;\n\n\t\tStroked( color, 1.5f );\n\t\tOutline(\n\t\t\tcentre - down * length \u002B side * half,\n\t\t\tcentre \u002B down * length \u002B side * half,\n\t\t\tcentre \u002B down * length - side * half,\n\t\t\tcentre - down * length - side * half );\n\n\t\t// The ferrule line across it, which is what separates an eraser from a plain rectangle.\n\t\tEditor.Paint.DrawLine( centre \u002B side * half, centre - side * half );\n\t}\n\n\tprivate static void PaintSculptBake( Vector2 c, Color color )\n\t{\n\t\t// The sculpted surface, up top.\n\t\tStroked( color.WithAlpha( 0.85f ), 1.5f );\n\t\tvar previous = At( c, -8.5f, -5f );\n\n\t\tfor ( var i = 1; i \u003C= 20; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = i / 20f;\n\t\t\tvar x = -8.5f \u002B 17f * t;\n\t\t\tvar y = -5f \u002B MathF.Sin( t * MathF.PI * 2f ) * 2.2f;\n\t\t\tvar point = At( c, x, y );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\n\t\t// Into the map.\n\t\tStroked( color.WithAlpha( 0.7f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, 0, -1f ), At( c, 0, 1.6f ) );\n\t\tArrowHead( At( c, 0, 2.8f ), new Vector2( 0, 1 ), color.WithAlpha( 0.7f ), 2.8f );\n\n\t\tStroked( color, 1.6f );\n\t\tOutline( At( c, -7, 4f ), At( c, 7, 4f ), At( c, 7, 9f ), At( c, -7, 9f ) );\n\n\t\tFilled( color.WithAlpha( 0.3f ) );\n\t\tEditor.Paint.DrawRect( Box( c, -7, 4f, 14f, 5f ) );\n\t}\n\n\t/// \u003Csummary\u003EThe shared baseline: a flat surface with one smooth bump in the middle of it. Every\n\t/// brush glyph starts from this so the row reads as one family acting on one thing.\u003C/summary\u003E\n\tprivate static void SurfaceWithBump( Vector2 c, float height )\n\t{\n\t\tvar previous = At( c, -8.5f, 5f );\n\n\t\tfor ( var i = 1; i \u003C= 24; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = i / 24f;\n\t\t\tvar x = -8.5f \u002B 17f * t;\n\n\t\t\t// A raised cosine, flat at both ends so it meets the surface without a corner.\n\t\t\tvar bump = 0.5f * (1f \u002B MathF.Cos( MathF.Max( MathF.Min( x / 5.5f, 1f ), -1f ) * MathF.PI ));\n\t\t\tvar point = At( c, x, 5f - bump * height );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A wall leaning off vertical, with the vertical it leans from left dashed beside it.\n\t///\n\t/// The angle IS the operation, so the glyph is the angle. Drawing a moulded part instead would\n\t/// say \u0022moulding\u0022 and leave you guessing which of the six tools on the strip does the leaning.\n\t/// \u003C/summary\u003E\n\t/// \u003Csummary\u003E\n\t/// A face lifting off the solid it belongs to: the body drawn faintly where it was, the face\n\t/// itself solid at its new height, and an arrow between them.\n\t///\n\t/// THE GHOST IS THE WHOLE GLYPH. Without it this is an arrow over a rectangle, which is what\n\t/// Transform looks like; with it, the picture is of one face of a part having moved and the rest\n\t/// having stayed \u2014 which is exactly what the tool does and what tells it apart from Draft\n\t/// standing next to it.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintMoveFace( Vector2 c, Color color )\n\t{\n\t\t// Where the face was, and the walls that stretched to follow it.\n\t\tStroked( color.WithAlpha( 0.35f ), 1.1f );\n\t\tOutline( At( c, -7f, 1f ), At( c, 7f, 1f ), At( c, 7f, 8f ), At( c, -7f, 8f ) );\n\n\t\t// The face, at its new height.\n\t\tStroked( color, 1.7f );\n\t\tEditor.Paint.DrawLine( At( c, -7f, -5f ), At( c, 7f, -5f ) );\n\n\t\t// The sides it dragged up with it.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, -7f, -5f ), At( c, -7f, 1f ) );\n\t\tEditor.Paint.DrawLine( At( c, 7f, -5f ), At( c, 7f, 1f ) );\n\n\t\t// Which way it went.\n\t\tStroked( color, 1.6f );\n\t\tEditor.Paint.DrawLine( At( c, 0f, -1f ), At( c, 0f, -8f ) );\n\t\tEditor.Paint.DrawLine( At( c, -3f, -5f ), At( c, 0f, -8f ) );\n\t\tEditor.Paint.DrawLine( At( c, 3f, -5f ), At( c, 0f, -8f ) );\n\t}\n\n\tprivate static void PaintDraft( Vector2 c, Color color )\n\t{\n\t\t// The parting line the taper is measured from.\n\t\tStroked( color.WithAlpha( 0.45f ), 1.1f );\n\t\tfor ( var x = -9f; x \u003C 9f; x \u002B= 3.4f )\n\t\t\tEditor.Paint.DrawLine( At( c, x, 0 ), At( c, x \u002B 2f, 0 ) );\n\n\t\t// The tapered wall: narrow at the top, wide at the bottom, closed as an outline.\n\t\tStroked( color, 1.7f );\n\t\tOutline( At( c, -3.4f, -8 ), At( c, 3.4f, -8 ), At( c, 6.6f, 8 ), At( c, -6.6f, 8 ) );\n\n\t\t// The vertical it is leaning away from, so the lean reads as deliberate rather than as a\n\t\t// wonky rectangle.\n\t\tStroked( color.WithAlpha( 0.5f ), 1f );\n\t\tEditor.Paint.DrawLine( At( c, 3.4f, -8 ), At( c, 3.4f, 8 ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A counterbore in section: a wide mouth stepping down to a narrow shaft, through a plate.\n\t///\n\t/// Drawn as a SECTION rather than as a circle on a surface, because a circle is what every other\n\t/// round thing on this strip already looks like from above - and the step is the whole reason\n\t/// this is a feature rather than a sketched circle.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintHole( Vector2 c, Color color )\n\t{\n\t\t// The plate, in section.\n\t\tStroked( color.WithAlpha( 0.8f ), 1.5f );\n\t\tEditor.Paint.DrawLine( At( c, -9, -6 ), At( c, -3.2f, -6 ) );\n\t\tEditor.Paint.DrawLine( At( c, 3.2f, -6 ), At( c, 9, -6 ) );\n\t\tEditor.Paint.DrawLine( At( c, -9, 7 ), At( c, 9, 7 ) );\n\t\tEditor.Paint.DrawLine( At( c, -9, -6 ), At( c, -9, 7 ) );\n\t\tEditor.Paint.DrawLine( At( c, 9, -6 ), At( c, 9, 7 ) );\n\n\t\t// The bore: wide at the mouth, stepping in to the shaft.\n\t\tStroked( color, 1.7f );\n\t\tEditor.Paint.DrawLine( At( c, -3.2f, -6 ), At( c, -3.2f, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, -3.2f, -1 ), At( c, -1.4f, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, -1.4f, -1 ), At( c, -1.4f, 7 ) );\n\n\t\tEditor.Paint.DrawLine( At( c, 3.2f, -6 ), At( c, 3.2f, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, 3.2f, -1 ), At( c, 1.4f, -1 ) );\n\t\tEditor.Paint.DrawLine( At( c, 1.4f, -1 ), At( c, 1.4f, 7 ) );\n\n\t\t// The void itself, so the shape reads as absence rather than as a post.\n\t\tFilled( color.WithAlpha( 0.18f ) );\n\t\tEditor.Paint.DrawPolygon(\n\t\t\tAt( c, -3.2f, -6 ), At( c, 3.2f, -6 ), At( c, 3.2f, -1 ), At( c, 1.4f, -1 ),\n\t\t\tAt( c, 1.4f, 7 ), At( c, -1.4f, 7 ), At( c, -1.4f, -1 ), At( c, -3.2f, -1 ) );\n\t}\n\t// --- the six sketch tools -------------------------------------------------------------------\n\t//\n\t// The four EDIT tools all show the same thing: a curve, and what the tool does to it, with the\n\t// part being removed or added drawn faintly. A trim that showed only the result would be\n\t// indistinguishable from a plain line at 27 pixels.\n\n\t/// \u003Csummary\u003EAn ellipse, with its long axis marked so it is not mistaken for a circle.\u003C/summary\u003E\n\tprivate static void PaintEllipseTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\n\t\tvar previous = Vector2.Zero;\n\n\t\tfor ( var i = 0; i \u003C= 40; i\u002B\u002B )\n\t\t{\n\t\t\tvar a = i / 40f * MathF.PI * 2f;\n\t\t\tvar point = At( c, MathF.Cos( a ) * 8.6f, MathF.Sin( a ) * 5f );\n\n\t\t\tif ( i \u003E 0 )\n\t\t\t\tEditor.Paint.DrawLine( previous, point );\n\n\t\t\tprevious = point;\n\t\t}\n\n\t\tStroked( color.WithAlpha( 0.5f ), 1.1f );\n\t\tEditor.Paint.DrawLine( At( c, -8.6f, 0 ), At( c, 8.6f, 0 ) );\n\t}\n\n\t/// \u003Csummary\u003EA curve through its control points, which is what a spline IS - the points are the\n\t/// thing you place and the curve is what follows.\u003C/summary\u003E\n\tprivate static void PaintSplineTool( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.7f );\n\n\t\tvar previous = Vector2.Zero;\n\n\t\t// A cubic-ish wiggle through the three dots below.\n\t\tfor ( var i = 0; i \u003C= 32; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = i / 32f;\n\t\t\tvar x = -7.5f \u002B 15f * t;\n\t\t\tvar y = MathF.Sin( t * MathF.PI * 1.6f \u002B 0.4f ) * 5.2f - 1f;\n\t\t\tvar point = At( c, x, y );\n\n\t\t\tif ( i \u003E 0 )\n\t\t\t\tEditor.Paint.DrawLine( previous, point );\n\n\t\t\tprevious = point;\n\t\t}\n\n\t\tFilled( color );\n\n\t\t// 15 UNITS OF CURVE, NOT 17. The end dots are centred on the curve\u0027s own ends and are 3\n\t\t// units across, so a curve spanning 17 drew a glyph spanning 20 in a box of 18. Fifteen\n\t\t// plus the two half-dots is exactly 18. The dot positions are recomputed from the same\n\t\t// expression as the curve rather than being written out, so the two cannot drift apart.\n\t\tforeach ( var t in new[] { 0f, 0.5f, 1f } )\n\t\t{\n\t\t\tvar x = -7.5f \u002B 15f * t;\n\t\t\tvar y = MathF.Sin( t * MathF.PI * 1.6f \u002B 0.4f ) * 5.2f - 1f;\n\n\t\t\tEditor.Paint.DrawRect( Box( c, x - 1.5f, y - 1.5f, 3f, 3f ), 1.5f * _scale );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003ETwo crossing lines with the stub past the crossing drawn faintly - the piece that\n\t/// goes. Trim is defined by what it removes, so that is what the glyph shows.\u003C/summary\u003E\n\tprivate static void PaintTrimTool( Vector2 c, Color color )\n\t{\n\t\t// The cutting line.\n\t\tStroked( color.WithAlpha( 0.55f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, 2.5f, -8.5f ), At( c, 2.5f, 8.5f ) );\n\n\t\t// The part that stays.\n\t\tStroked( color, 1.9f );\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, 3f ), At( c, 2.5f, 0f ) );\n\n\t\t// The part that goes, dashed.\n\t\tStroked( color.WithAlpha( 0.35f ), 1.5f );\n\t\tfor ( var t = 0f; t \u003C 1f; t \u002B= 0.28f )\n\t\t{\n\t\t\tvar a = At( c, 2.5f \u002B 6f * t, -1.6f * t );\n\t\t\tvar b = At( c, 2.5f \u002B 6f * (t \u002B 0.16f), -1.6f * (t \u002B 0.16f) );\n\n\t\t\tEditor.Paint.DrawLine( a, b );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EA line reaching a boundary, with the new length drawn faintly and an arrow head -\n\t/// the mirror of Trim, and drawn as its mirror so the pair reads as a pair.\u003C/summary\u003E\n\tprivate static void PaintExtendTool( Vector2 c, Color color )\n\t{\n\t\t// The boundary it reaches to.\n\t\tStroked( color.WithAlpha( 0.55f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, 6.5f, -8.5f ), At( c, 6.5f, 8.5f ) );\n\n\t\t// What is there now.\n\t\tStroked( color, 1.9f );\n\t\tEditor.Paint.DrawLine( At( c, -8.5f, 3f ), At( c, -1f, 1f ) );\n\n\t\t// Where it is going.\n\t\tStroked( color.WithAlpha( 0.4f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -1f, 1f ), At( c, 5f, -0.6f ) );\n\t\tArrowHead( At( c, 6.4f, -1f ), new Vector2( 1f, -0.26f ), color.WithAlpha( 0.75f ), 3f );\n\t}\n\n\t/// \u003Csummary\u003EA rounded corner with the square one it replaces dashed behind it.\u003C/summary\u003E\n\tprivate static void PaintSketchFilletTool( Vector2 c, Color color )\n\t{\n\t\t// The corner that was.\n\t\tStroked( color.WithAlpha( 0.35f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, -8, -7 ), At( c, 7, -7 ) );\n\t\tEditor.Paint.DrawLine( At( c, 7, -7 ), At( c, 7, 8 ) );\n\n\t\t// The corner that is.\n\t\tStroked( color, 1.9f );\n\t\tEditor.Paint.DrawLine( At( c, -8, -7 ), At( c, 0f, -7 ) );\n\t\tArc( At( c, 0f, 0f ), 7f, -90f, 0f, 12 );\n\t\tEditor.Paint.DrawLine( At( c, 7, 0f ), At( c, 7, 8 ) );\n\t}\n\n\t/// \u003Csummary\u003EA shape and a second one running parallel outside it, which is the whole of what an\n\t/// offset is - the same curve, held away at a distance.\u003C/summary\u003E\n\tprivate static void PaintOffsetTool( Vector2 c, Color color )\n\t{\n\t\t// The original.\n\t\tStroked( color, 1.8f );\n\t\tEditor.Paint.DrawLine( At( c, -6, 6 ), At( c, -6, -2 ) );\n\t\tArc( At( c, -1.5f, -2f ), 4.5f, 180f, 270f, 10 );\n\t\tEditor.Paint.DrawLine( At( c, -1.5f, -6.5f ), At( c, 5, -6.5f ) );\n\n\t\t// Its offset, outside and parallel.\n\t\tStroked( color.WithAlpha( 0.55f ), 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -9.5f, 6 ), At( c, -9.5f, -2 ) );\n\t\tArc( At( c, -1.5f, -2f ), 8f, 180f, 270f, 12 );\n\t\tEditor.Paint.DrawLine( At( c, -1.5f, -10f ), At( c, 5, -10f ) );\n\t}\n\n\t/// \u003Csummary\u003EThe face\u0027s outline drawn faint, with ONE of its edges taken - solid, and in the\n\t/// green the viewport paints reference geometry, so the button and the thing it acts on are\n\t/// obviously the same thing.\u003C/summary\u003E\n\tprivate static void PaintUseTool( Vector2 c, Color color )\n\t{\n\t\tFaint( color );\n\t\tOutline( At( c, -7, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );\n\n\t\t// The one edge that has been taken.\n\t\tStroked( ReferenceColor, 2.6f );\n\t\tEditor.Paint.DrawLine( At( c, -7, 7 ), At( c, 7, 7 ) );\n\n\t\tClickDot( At( c, 0, 7 ) );\n\t}\n\n\t/// \u003Csummary\u003EThe same square with every edge taken, which is what the button does in one press.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintUseAllTool( Vector2 c, Color color )\n\t{\n\t\tFaint( color );\n\t\tEditor.Paint.DrawLine( At( c, -7, 0 ), At( c, 7, 0 ) );\n\n\t\tStroked( ReferenceColor, 2.6f );\n\t\tOutline( At( c, -7, -7 ), At( c, 7, -7 ), At( c, 7, 7 ), At( c, -7, 7 ) );\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// A freehand stroke swept across three lines, with the pieces it went through dashed away.\n\t///\n\t/// The STROKE is the subject and is drawn in the red the viewport draws it in, because that is\n\t/// the thing the button is offering to let you do. Trim\u0027s glyph shows one clean crossing; this\n\t/// one shows a wobble through several, which is the difference between the two tools.\n\t/// \u003C/summary\u003E\n\tprivate static void PaintCutTool( Vector2 c, Color color )\n\t{\n\t\t// Three uprights, each with the piece the stroke went through faded out. The gap is what the\n\t\t// tool does; the two solid ends are what it leaves.\n\t\tfor ( var i = 0; i \u003C 3; i\u002B\u002B )\n\t\t{\n\t\t\tvar x = -6f \u002B i * 6f;\n\n\t\t\tStroked( color, 1.8f );\n\t\t\tEditor.Paint.DrawLine( At( c, x, -8.5f ), At( c, x, -2.5f ) );\n\t\t\tEditor.Paint.DrawLine( At( c, x, 3.5f ), At( c, x, 8.5f ) );\n\n\t\t\tStroked( color.WithAlpha( 0.28f ), 1.4f );\n\t\t\tEditor.Paint.DrawLine( At( c, x, -2.5f ), At( c, x, 3.5f ) );\n\t\t}\n\n\t\t// The stroke itself, sagging so that it passes through all three gaps rather than running\n\t\t// straight - straight would be Trim\u0027s glyph with two more lines in it.\n\t\tStroked( CutColor, 2.1f );\n\n\t\tvar previous = At( c, -9f, -2f );\n\n\t\tfor ( var i = 1; i \u003C= 12; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = i / 12f;\n\t\t\tvar point = At( c, -9f \u002B 18f * t, -2f \u002B MathF.Sin( t * MathF.PI ) * 3.5f );\n\n\t\t\tEditor.Paint.DrawLine( previous, point );\n\t\t\tprevious = point;\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EThe part of a Use glyph that is still only scenery.\u003C/summary\u003E\n\tprivate static void Faint( Color color ) =\u003E Stroked( color.WithAlpha( 0.4f ), 1.3f );\n\n\t/// \u003Csummary\u003EThe green the sketcher paints a face\u0027s outline in - see SketchReferenceColor in\n\t/// EffigyViewport.Sketching.cs. Kept in step by eye rather than shared, because that one carries\n\t/// an alpha for drawing in the world and this one has to read on a small dark button.\u003C/summary\u003E\n\tprivate static readonly Color ReferenceColor = new( 0.45f, 1f, 0.6f, 1f );\n\n\t/// \u003Csummary\u003EThe red a cut stroke is drawn in - see SketchCutColor in\n\t/// EffigyViewport.SketchTools.cs. Kept in step by eye, the same as ReferenceColor above: that\n\t/// one carries an alpha for drawing in the world and this one has to read on a small dark\n\t/// button.\u003C/summary\u003E\n\tprivate static readonly Color CutColor = new( 1f, 0.45f, 0.35f, 1f );\n\n\t// --- lighting ------------------------------------------------------------------------------\n\t//\n\t// THE THREE RIGS ARE THE SAME SPHERE, LIT THREE WAYS. Three-point, rim and top-down differ only\n\t// in where the light comes from, so drawing them as three different objects would invent a\n\t// distinction that is not there. Each is a circle with the lit part filled and the rest left as\n\t// outline, which is the difference itself rather than a symbol standing in for it.\n\n\t/// \u003Csummary\u003EThe lit sphere the rig glyphs share: an outlined circle with a filled crescent on\n\t/// whichever side the light is on. \u003Cparamref name=\u0022from\u0022/\u003E is the direction the light arrives\n\t/// from, in icon space.\u003C/summary\u003E\n\tprivate static void LitSphere( Vector2 c, Color color, Vector2 from, float radius = 5.2f )\n\t{\n\t\tvar d = from.Normal;\n\n\t\t// The terminator is perpendicular to the light, so the lit cap runs 180 degrees centred on\n\t\t// the light\u0027s own bearing. Drawn as a filled polygon fan rather than an arc, because an\n\t\t// outline would read as a second circle rather than as brightness.\n\t\tvar bearing = MathF.Atan2( d.y, d.x ) * 180f / MathF.PI;\n\t\tvar points = new List\u003CVector2\u003E();\n\n\t\tfor ( var i = 0; i \u003C= 18; i\u002B\u002B )\n\t\t{\n\t\t\tvar t = (bearing - 90f) \u002B 180f * (i / 18f);\n\t\t\tvar radians = t * MathF.PI / 180f;\n\t\t\tpoints.Add( At( c, MathF.Cos( radians ) * radius, MathF.Sin( radians ) * radius ) );\n\t\t}\n\n\t\tFilled( color.WithAlpha( 0.85f ) );\n\t\tEditor.Paint.DrawPolygon( points.ToArray() );\n\n\t\tStroked( color, 1.4f );\n\t\tArc( c, radius, 0f, 360f, 28 );\n\t}\n\n\t/// \u003Csummary\u003EA short ray coming in toward the sphere, so the glyph says which side the lamp is\n\t/// on as well as which side is bright.\u003C/summary\u003E\n\tprivate static void LightRay( Vector2 c, Color color, Vector2 from, float outer = 8.6f, float inner = 6.4f )\n\t{\n\t\tvar d = from.Normal;\n\n\t\tStroked( color.WithAlpha( 0.9f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, d.x * outer, d.y * outer ), At( c, d.x * inner, d.y * inner ) );\n\t}\n\n\t/// \u003Csummary\u003EEven light from every side: a circle with rays all the way round, none of them\n\t/// longer than the others. Full bright has no key direction and the glyph must not imply\n\t/// one \u2014 that is the entire difference between this and the sun below.\u003C/summary\u003E\n\tprivate static void PaintLightFullBright( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.9f ) );\n\t\tEditor.Paint.DrawCircle( c, 4.1f * _scale );\n\n\t\tStroked( color, 1.3f );\n\n\t\tfor ( var i = 0; i \u003C 8; i\u002B\u002B )\n\t\t{\n\t\t\tvar radians = i * 45f * MathF.PI / 180f;\n\t\t\tvar d = new Vector2( MathF.Cos( radians ), MathF.Sin( radians ) );\n\n\t\t\tEditor.Paint.DrawLine( At( c, d.x * 6f, d.y * 6f ), At( c, d.x * 8.4f, d.y * 8.4f ) );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EA bulb: glass, a screw base, and three rays. The one lamp with no direction, drawn\n\t/// as the object rather than as its effect.\u003C/summary\u003E\n\tprivate static void PaintLightPoint( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.5f );\n\t\tArc( c, 4.2f, 0f, 360f, 24 );\n\n\t\t// The base, under the glass.\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, 5.2f ), At( c, 2.2f, 5.2f ) );\n\t\tEditor.Paint.DrawLine( At( c, -1.6f, 7f ), At( c, 1.6f, 7f ) );\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, 5.2f ), At( c, -1.6f, 7f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.2f, 5.2f ), At( c, 1.6f, 7f ) );\n\n\t\tStroked( color.WithAlpha( 0.8f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, -7.4f, -4.6f ), At( c, -5.6f, -3.4f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0f, -8.2f ), At( c, 0f, -6.2f ) );\n\t\tEditor.Paint.DrawLine( At( c, 7.4f, -4.6f ), At( c, 5.6f, -3.4f ) );\n\t}\n\n\t/// \u003Csummary\u003EA cone of light widening downward, with the pool it lands in. Aimed, which is the\n\t/// property that separates it from the bulb.\u003C/summary\u003E\n\tprivate static void PaintLightSpot( Vector2 c, Color color )\n\t{\n\t\t// The housing.\n\t\tStroked( color, 1.5f );\n\t\tOutline(\n\t\t\tAt( c, -3f, -7.6f ),\n\t\t\tAt( c, 3f, -7.6f ),\n\t\t\tAt( c, 2.2f, -4.4f ),\n\t\t\tAt( c, -2.2f, -4.4f ) );\n\n\t\t// The cone, dimmer than the lamp that casts it.\n\t\tStroked( color.WithAlpha( 0.75f ), 1.3f );\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, -4.4f ), At( c, -6.4f, 5.2f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.2f, -4.4f ), At( c, 6.4f, 5.2f ) );\n\n\t\t// The pool on the floor, flattened so it reads as ground rather than as a ball.\n\t\tEllipseArc( At( c, 0f, 5.2f ), 6.4f, 1.9f, 0f, 360f, 26 );\n\t}\n\n\t/// \u003Csummary\u003EParallel rays at an angle: a directional light has no position, only a bearing, and\n\t/// parallel is the one thing that says \u0022these rays never converge\u0022.\u003C/summary\u003E\n\tprivate static void PaintLightSun( Vector2 c, Color color )\n\t{\n\t\tFilled( color.WithAlpha( 0.9f ) );\n\t\tEditor.Paint.DrawCircle( At( c, -3.4f, -3.4f ), 3.2f * _scale );\n\n\t\tStroked( color.WithAlpha( 0.85f ), 1.3f );\n\n\t\t// Three rays on the same bearing, spaced across the diagonal. Arrow heads because a sun is\n\t\t// the only lamp here whose glyph would otherwise be three plain lines.\n\t\tfor ( var i = -1; i \u003C= 1; i\u002B\u002B )\n\t\t{\n\t\t\tvar offset = new Vector2( i * 4.6f, -i * 4.6f );\n\t\t\tvar tail = At( c, 1.4f \u002B offset.x, 1.4f \u002B offset.y );\n\t\t\tvar tip = At( c, 6.6f \u002B offset.x, 6.6f \u002B offset.y );\n\n\t\t\tEditor.Paint.DrawLine( tail, tip );\n\t\t\tArrowHead( tip, new Vector2( 1f, 1f ), color.WithAlpha( 0.85f ), 2.8f );\n\t\t\tStroked( color.WithAlpha( 0.85f ), 1.3f );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003EKey, fill and rim: the sphere lit from the upper right, with the two weaker lamps\n\t/// marked as rays on the sides they come from.\u003C/summary\u003E\n\tprivate static void PaintLightRigThreePoint( Vector2 c, Color color )\n\t{\n\t\tLitSphere( c, color, new Vector2( 1f, -1f ) );\n\n\t\tLightRay( c, color, new Vector2( 1f, -1f ) );\n\t\tLightRay( c, color.WithAlpha( 0.5f ), new Vector2( -1f, -0.2f ) );\n\t\tLightRay( c, color.WithAlpha( 0.65f ), new Vector2( -0.3f, 1f ) );\n\t}\n\n\t/// \u003Csummary\u003ELit from behind: the far edge glows and the face is dark. The one rig whose whole\n\t/// point is the outline rather than the surface.\u003C/summary\u003E\n\tprivate static void PaintLightRigRim( Vector2 c, Color color )\n\t{\n\t\tStroked( color.WithAlpha( 0.55f ), 1.4f );\n\t\tArc( c, 5.2f, 0f, 360f, 28 );\n\n\t\t// Only the far crescent, drawn heavy. No fill at all, because a rim light leaves the body\n\t\t// of the shape unlit and filling it would be the opposite of what this means.\n\t\tStroked( color, 2.4f );\n\t\tArc( c, 5.2f, -155f, -25f, 20 );\n\n\t\tLightRay( c, color, new Vector2( 0.2f, -1f ) );\n\t}\n\n\t/// \u003Csummary\u003ELit from straight above: the lamp is drawn, and the top of the sphere is bright.\u003C/summary\u003E\n\tprivate static void PaintLightRigTop( Vector2 c, Color color )\n\t{\n\t\tStroked( color, 1.4f );\n\t\tEditor.Paint.DrawLine( At( c, -4.2f, -8f ), At( c, 4.2f, -8f ) );\n\n\t\tStroked( color.WithAlpha( 0.8f ), 1.2f );\n\t\tEditor.Paint.DrawLine( At( c, -2.6f, -7.2f ), At( c, -3.6f, -5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 0f, -7.2f ), At( c, 0f, -5f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.6f, -7.2f ), At( c, 3.6f, -5f ) );\n\n\t\tLitSphere( At( c, 0f, 1.6f ), color, new Vector2( 0f, -1f ), 4.6f );\n\t}\n\n\t/// \u003Csummary\u003EOne lamp and nothing else: the sphere lit hard from one side, the other side left\n\t/// empty. No fill ray anywhere, which is the difference from three-point.\u003C/summary\u003E\n\tprivate static void PaintLightRigKey( Vector2 c, Color color )\n\t{\n\t\tLitSphere( c, color, new Vector2( 1f, -1f ) );\n\t\tLightRay( c, color, new Vector2( 1f, -1f ) );\n\t}\n\n\t/// \u003Csummary\u003EA bulb with a stroke through it. Clear is the only button on the stage that takes\n\t/// light away, and a struck-through glyph is the one shape that reads as removal without\n\t/// needing a colour to say so.\u003C/summary\u003E\n\tprivate static void PaintLightClear( Vector2 c, Color color )\n\t{\n\t\tvar dim = color.WithAlpha( 0.55f );\n\n\t\tStroked( dim, 1.5f );\n\t\tArc( At( c, 0f, -1f ), 4.2f, 0f, 360f, 24 );\n\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, 4.2f ), At( c, 2.2f, 4.2f ) );\n\t\tEditor.Paint.DrawLine( At( c, -1.6f, 6f ), At( c, 1.6f, 6f ) );\n\t\tEditor.Paint.DrawLine( At( c, -2.2f, 4.2f ), At( c, -1.6f, 6f ) );\n\t\tEditor.Paint.DrawLine( At( c, 2.2f, 4.2f ), At( c, 1.6f, 6f ) );\n\n\t\tStroked( color, 1.9f );\n\t\tEditor.Paint.DrawLine( At( c, -7f, 7f ), At( c, 7f, -7f ) );\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Editor/EffigyEditor/EffigyViewport.Selection.cs","FileName":"EffigyViewport.Selection.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using Editor;\nusing Effigy;\nusing Sandbox;\nusing System;\nusing System.Collections.Generic;\n\nnamespace Marionette.EditorTools;\n\n/// \u003Csummary\u003E\n/// Idle geometry selection \u2014 click a face or a part, then use a tool on it.\n///\n/// Separate from FacePickMode / BodyPickMode. Those are a dialog asking a question; this is the\n/// selection that exists when nobody is asking. Onshape works the same way: you point at a face,\n/// it lights up, you click it, then Draft/Hole/Sketch consume what you already picked instead of\n/// making you pick it again inside a dialog.\n/// \u003C/summary\u003E\ninternal sealed partial class EffigyViewport\n{\n\t/// \u003Csummary\u003EFaces currently selected while idle. Empty when the last click was a whole part\n\t/// in the Parts list, or nothing.\u003C/summary\u003E\n\tpublic IReadOnlyList\u003CFaceRef\u003E IdleFaces =\u003E _idleFaces;\n\n\t/// \u003Csummary\u003EEdges currently selected while idle. Empty unless the last click landed near a\n\t/// rim rather than in the middle of a face.\u003C/summary\u003E\n\tpublic IReadOnlyList\u003CEdgeRef\u003E IdleEdges =\u003E _idleEdges;\n\n\t/// \u003Csummary\u003EBodies currently selected while idle. A face click names its owning body so the\n\t/// Parts list can highlight the same row; a Parts-list click names the body and no faces.\n\t/// \u003C/summary\u003E\n\tpublic IReadOnlyList\u003Cstring\u003E IdleBodyIds =\u003E _idleBodyIds;\n\n\t/// \u003Csummary\u003ECommitted sketch currently selected while idle, or null. Extrude/Revolve read\n\t/// this instead of waiting for a second click.\u003C/summary\u003E\n\tpublic string IdleSketchFeatureId =\u003E _idleSketchFeatureId;\n\n\t/// \u003Csummary\u003ERegion seeds on \u003Csee cref=\u0022IdleSketchFeatureId\u0022/\u003E. Empty means the whole sketch.\n\t/// \u003C/summary\u003E\n\tpublic IReadOnlyList\u003CVec2\u003E IdleRegionSeeds =\u003E _idleRegionSeeds;\n\n\tpublic bool HasIdleSelection =\u003E\n\t\t_idleFaces.Count \u003E 0 || _idleEdges.Count \u003E 0 || _idleBodyIds.Count \u003E 0\n\t\t|| _idleSketchFeatureId is not null;\n\n\t/// \u003Csummary\u003ERaised after a click in the viewport changes the idle selection, so the Parts\n\t/// list can stay in step. Not raised when the list itself drove the change.\u003C/summary\u003E\n\tpublic Action IdleSelectionChanged { get; set; }\n\n\tprivate readonly List\u003CFaceRef\u003E _idleFaces = new();\n\tprivate readonly List\u003CEdgeRef\u003E _idleEdges = new();\n\tprivate readonly List\u003Cstring\u003E _idleBodyIds = new();\n\tprivate string _idleSketchFeatureId;\n\tprivate readonly List\u003CVec2\u003E _idleRegionSeeds = new();\n\n\t/// \u003Csummary\u003EHow close a click must be to a rim, in screen pixels, before it counts as an\n\t/// edge rather than the face. Wide enough to grab, tight enough that a click in the middle\n\t/// of a face stays a face.\u003C/summary\u003E\n\tprivate const float EdgePickPixels = 10f;\n\n\t/// \u003Csummary\u003EWhether a left click on a face is a selection rather than an answer to a dialog.\n\t/// Anything else with a click of its own owns the mouse while it is armed.\u003C/summary\u003E\n\tprivate bool IdlePickingAllowed =\u003E\n\t\t!IsSketching \u0026\u0026 !IsSculpting \u0026\u0026 !IsNoting\n\t\t\u0026\u0026 !PlanePickMode \u0026\u0026 !SketchPickMode \u0026\u0026 !FacePickMode \u0026\u0026 !EdgePickMode \u0026\u0026 !BodyPickMode\n\t\t\u0026\u0026 !BoneToolActive \u0026\u0026 !_draggingOrigin \u0026\u0026 !_draggingLight \u0026\u0026 !_draggingFace;\n\n\t/// \u003Csummary\u003E\n\t/// Replace the idle selection with whole bodies, clearing any face picks.\n\t///\n\t/// This is what the Parts list does: clicking a row selects that part, not one of its faces.\n\t/// Fillet, shell, transform then act on the part; Sketch/Draft still need a face, so they\n\t/// open asking for one.\n\t/// \u003C/summary\u003E\n\tpublic void SelectBodies( IReadOnlyList\u003Cstring\u003E bodyIds )\n\t{\n\t\t_idleFaces.Clear();\n\t\t_idleEdges.Clear();\n\t\t_idleBodyIds.Clear();\n\t\tClearIdleSketch();\n\n\t\tif ( bodyIds is not null )\n\t\t{\n\t\t\tforeach ( var id in bodyIds )\n\t\t\t{\n\t\t\t\tif ( !string.IsNullOrEmpty( id ) \u0026\u0026 !_idleBodyIds.Contains( id ) )\n\t\t\t\t\t_idleBodyIds.Add( id );\n\t\t\t}\n\t\t}\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\tpublic void ClearIdleSelection()\n\t{\n\t\tif ( !HasIdleSelection )\n\t\t\treturn;\n\n\t\t_idleFaces.Clear();\n\t\t_idleEdges.Clear();\n\t\t_idleBodyIds.Clear();\n\t\tClearIdleSketch();\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\tvoid ClearIdleSketch()\n\t{\n\t\t_idleSketchFeatureId = null;\n\t\t_idleRegionSeeds.Clear();\n\t}\n\n\t/// \u003Csummary\u003ESelect a committed sketch, optionally a single closed region of it.\u003C/summary\u003E\n\tpublic void SelectIdleSketch( string featureId, Vec2? seed, bool add = false )\n\t{\n\t\tif ( string.IsNullOrEmpty( featureId ) )\n\t\t{\n\t\t\tif ( _idleSketchFeatureId is null )\n\t\t\t\treturn;\n\n\t\t\tClearIdleSketch();\n\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\treturn;\n\t\t}\n\n\t\tif ( add \u0026\u0026 _idleSketchFeatureId == featureId \u0026\u0026 seed is { } extra )\n\t\t{\n\t\t\tfor ( var i = 0; i \u003C _idleRegionSeeds.Count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar existing = _idleRegionSeeds[i];\n\n\t\t\t\tif ( MathF.Abs( existing.x - extra.x ) \u003E 1e-4f || MathF.Abs( existing.y - extra.y ) \u003E 1e-4f )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t_idleRegionSeeds.RemoveAt( i );\n\n\t\t\t\tif ( _idleRegionSeeds.Count == 0 )\n\t\t\t\t\t_idleSketchFeatureId = null;\n\n\t\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t_idleRegionSeeds.Add( extra );\n\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\treturn;\n\t\t}\n\n\t\t_idleFaces.Clear();\n\t\t_idleEdges.Clear();\n\t\t_idleBodyIds.Clear();\n\t\t_idleSketchFeatureId = featureId;\n\t\t_idleRegionSeeds.Clear();\n\n\t\tif ( seed is { } one )\n\t\t\t_idleRegionSeeds.Add( one );\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\t/// \u003Csummary\u003EDrop faces and parts whose bodies no longer exist after a rebuild. Called from\n\t/// SetDisplayBodies so a deleted part cannot stay selected against nothing.\u003C/summary\u003E\n\tprivate void PruneIdleSelection()\n\t{\n\t\tif ( !HasIdleSelection )\n\t\t\treturn;\n\n\t\tvar ids = new HashSet\u003Cstring\u003E();\n\n\t\tforeach ( var body in _displayBodies )\n\t\t{\n\t\t\tif ( body?.Id is { } id )\n\t\t\t\tids.Add( id );\n\t\t}\n\n\t\tvar changed = _idleFaces.RemoveAll( f =\u003E !ids.Contains( f.BodyId ) ) \u003E 0\n\t\t\t| _idleEdges.RemoveAll( e =\u003E !ids.Contains( e.BodyId ) ) \u003E 0\n\t\t\t| _idleBodyIds.RemoveAll( id =\u003E !ids.Contains( id ) ) \u003E 0;\n\n\t\tif ( _idleSketchFeatureId is not null )\n\t\t{\n\t\t\tvar found = false;\n\n\t\t\tforeach ( var pickable in _pickableSketches )\n\t\t\t{\n\t\t\t\tif ( pickable.FeatureId == _idleSketchFeatureId )\n\t\t\t\t{\n\t\t\t\t\tfound = true;\n\t\t\t\t\tbreak;\n\t\t\t\t}\n\t\t\t}\n\n\t\t\tif ( !found )\n\t\t\t{\n\t\t\t\tClearIdleSketch();\n\t\t\t\tchanged = true;\n\t\t\t}\n\t\t}\n\n\t\tif ( changed )\n\t\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Hover the face under the cursor, keep already-picked faces and parts lit, and take a click\n\t/// as a selection when nothing else owns the mouse.\n\t///\n\t/// Runs after the origin handle so a click on the origin is not also a click on the face that\n\t/// happens to sit behind it. Gizmo.HasHovered covers the origin, the plane-corner handles and\n\t/// anything else that registered a hitbox this frame.\n\t/// \u003C/summary\u003E\n\tprivate void IdleSelectionFrame()\n\t{\n\t\tDrawIdleSelection();\n\n\t\tif ( !IdlePickingAllowed )\n\t\t\treturn;\n\n\t\tif ( !_canvasHasCursor )\n\t\t\treturn;\n\n\t\tif ( TryResolveSketchHover( out var sketchId, out var sketchSeed, out var sketchDistance )\n\t\t\t\u0026\u0026 SketchBeatsFace( sketchDistance ) )\n\t\t{\n\t\t\t_hoveredFaceBodyId = sketchId;\n\t\t\tDrawSketchPickHighlight( sketchId, sketchSeed );\n\n\t\t\tif ( Gizmo.WasLeftMousePressed \u0026\u0026 !Gizmo.HasHovered )\n\t\t\t{\n\t\t\t\tif ( OriginSelected )\n\t\t\t\t\tDeselectOrigin();\n\n\t\t\t\tSelectIdleSketch( sketchId, sketchSeed, Gizmo.IsShiftPressed );\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\tif ( TryPickFaceUnderCursor( out var hit ) )\n\t\t{\n\t\t\t_hoveredFaceBodyId = hit.Body.Id;\n\n\t\t\tif ( TryIdleEdge( hit, out var edge, out var key ) )\n\t\t\t{\n\t\t\t\tDrawEdge( hit.Body, key, FaceHighlightColor );\n\n\t\t\t\tif ( Gizmo.WasLeftMousePressed \u0026\u0026 !Gizmo.HasHovered )\n\t\t\t\t{\n\t\t\t\t\tif ( OriginSelected )\n\t\t\t\t\t\tDeselectOrigin();\n\n\t\t\t\t\tSelectIdleEdge( hit.Body, edge, Gizmo.IsShiftPressed );\n\t\t\t\t}\n\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tDrawHoveredFace( hit.Body, hit.FaceIndex );\n\n\t\t\tif ( Gizmo.WasLeftMousePressed \u0026\u0026 !Gizmo.HasHovered )\n\t\t\t{\n\t\t\t\tif ( OriginSelected )\n\t\t\t\t\tDeselectOrigin();\n\n\t\t\t\tSelectIdleFace( hit, Gizmo.IsShiftPressed );\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\tif ( Gizmo.WasLeftMousePressed \u0026\u0026 !Gizmo.HasHovered \u0026\u0026 !Gizmo.IsHovered )\n\t\t\tClearIdleSelection();\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Chosen idle geometry, in the same amber the dialog uses for a committed pick.\n\t///\n\t/// Skipped when a dialog is already drawing its own SelectedFaces / SelectedBodyIds, so the\n\t/// two never paint the same face twice in slightly different states.\n\t/// \u003C/summary\u003E\n\t/// \u003Csummary\u003EA sketch on a face sits at the same depth as that face. Prefer the sketch when\n\t/// they tie; prefer a solid that is genuinely in front of the sketch plane.\u003C/summary\u003E\n\tprivate bool SketchBeatsFace( float sketchDistance )\n\t{\n\t\tif ( !TryPickFaceUnderCursor( out var hit ) )\n\t\t\treturn true;\n\n\t\tvar faceDistance = (hit.Reference.Point - _cursorRayOrigin).Length;\n\n\t\treturn sketchDistance \u003C= faceDistance \u002B 0.05f;\n\t}\n\n\tprivate void DrawIdleSelection()\n\t{\n\t\tif ( (SelectedSketchFeatureId is null || SelectedSketchFeatureId.Length == 0)\n\t\t\t\u0026\u0026 _idleSketchFeatureId is not null )\n\t\t{\n\t\t\tvar seed = _idleRegionSeeds.Count == 1 ? _idleRegionSeeds[0] : (Vec2?)null;\n\n\t\t\tif ( _idleRegionSeeds.Count \u003C= 1 )\n\t\t\t\tDrawSketchPickHighlight( _idleSketchFeatureId, seed );\n\t\t\telse\n\t\t\t{\n\t\t\t\tforeach ( var region in _idleRegionSeeds )\n\t\t\t\t\tDrawSketchPickHighlight( _idleSketchFeatureId, region );\n\t\t\t}\n\t\t}\n\n\t\tvar drawFaces = SelectedFaces is null || SelectedFaces.Count == 0;\n\t\tvar drawEdges = SelectedEdges is null || SelectedEdges.Count == 0;\n\t\tvar drawBodies = SelectedBodyIds is null || SelectedBodyIds.Count == 0;\n\n\t\tif ( drawEdges \u0026\u0026 _idleEdges.Count \u003E 0 )\n\t\t{\n\t\t\tforeach ( var edge in _idleEdges )\n\t\t\t{\n\t\t\t\tif ( FacePlane.TryResolveEdge( _displayBodies, edge, out var body, out var key ) )\n\t\t\t\t\tDrawEdge( body, key, FaceSelectedColor );\n\t\t\t}\n\t\t}\n\n\t\tif ( drawFaces \u0026\u0026 _idleFaces.Count \u003E 0 )\n\t\t{\n\t\t\tforeach ( var face in _idleFaces )\n\t\t\t{\n\t\t\t\tif ( FacePlane.TryResolveFace( _displayBodies, face, out var body, out var index ) )\n\t\t\t\t\tDrawFace( body, index, FaceSelectedColor );\n\t\t\t}\n\n\t\t\treturn;\n\t\t}\n\n\t\tif ( !drawBodies || _idleBodyIds.Count == 0 )\n\t\t\treturn;\n\n\t\tforeach ( var body in _displayBodies )\n\t\t{\n\t\t\tif ( body?.Id is { } id \u0026\u0026 _idleBodyIds.Contains( id ) )\n\t\t\t\tDrawBodyHighlight( body, BodySelectedColor );\n\t\t}\n\t}\n\n\t/// \u003Csummary\u003E\n\t/// Select one face outright, from something other than a click \u2014 the right-click menu, which\n\t/// has already resolved the face under the cursor and now wants a tool pointed at it.\n\t///\n\t/// Replaces the selection rather than adding to it: the face you just right-clicked is the face\n\t/// you meant, and quietly bundling it with whatever was lit a moment ago would hand the tool\n\t/// more than you asked for.\n\t/// \u003C/summary\u003E\n\tpublic void SelectFace( EffigyFaceHit hit ) =\u003E SelectIdleFace( hit, add: false );\n\n\t/// \u003Csummary\u003EClick a face: replace the selection, or Shift-click to toggle it in.\u003C/summary\u003E\n\tprivate void SelectIdleFace( EffigyFaceHit hit, bool add )\n\t{\n\t\tvar face = hit.Reference;\n\n\t\tif ( add )\n\t\t{\n\t\t\tfor ( var i = 0; i \u003C _idleFaces.Count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tif ( !SameIdleFace( _idleFaces[i], face ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t_idleFaces.RemoveAt( i );\n\t\t\t\tRebuildIdleBodyIdsFromFaces();\n\t\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t_idleEdges.Clear();\n\t\t\tClearIdleSketch();\n\t\t\t_idleFaces.Add( face );\n\n\t\t\tif ( !_idleBodyIds.Contains( hit.Body.Id ) )\n\t\t\t\t_idleBodyIds.Add( hit.Body.Id );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_idleFaces.Clear();\n\t\t\t_idleEdges.Clear();\n\t\t\t_idleBodyIds.Clear();\n\t\t\tClearIdleSketch();\n\t\t\t_idleFaces.Add( face );\n\t\t\t_idleBodyIds.Add( hit.Body.Id );\n\t\t}\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\tprivate void SelectIdleEdge( Body body, EdgeRef edge, bool add )\n\t{\n\t\tif ( add )\n\t\t{\n\t\t\tfor ( var i = 0; i \u003C _idleEdges.Count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tif ( !SameIdleEdge( _idleEdges[i], edge ) )\n\t\t\t\t\tcontinue;\n\n\t\t\t\t_idleEdges.RemoveAt( i );\n\t\t\t\tRebuildIdleBodyIdsFromFaces();\n\t\t\t\tIdleSelectionChanged?.Invoke();\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t_idleFaces.Clear();\n\t\t\tClearIdleSketch();\n\t\t\t_idleEdges.Add( edge );\n\n\t\t\tif ( !_idleBodyIds.Contains( body.Id ) )\n\t\t\t\t_idleBodyIds.Add( body.Id );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_idleFaces.Clear();\n\t\t\t_idleEdges.Clear();\n\t\t\t_idleBodyIds.Clear();\n\t\t\tClearIdleSketch();\n\t\t\t_idleEdges.Add( edge );\n\t\t\t_idleBodyIds.Add( body.Id );\n\t\t}\n\n\t\tIdleSelectionChanged?.Invoke();\n\t}\n\n\tprivate bool TryIdleEdge( EffigyFaceHit hit, out EdgeRef edge, out EdgeKey key )\n\t{\n\t\tedge = default;\n\t\tkey = default;\n\n\t\tif ( !TryClosestEdge( hit.Body.Mesh, hit.FaceIndex, hit.Reference.Point, out key, out var distance ) )\n\t\t\treturn false;\n\n\t\tvar point = hit.Reference.Point;\n\t\tvar threshold = WorldRadiusAt( new Vector3( point.x, point.y, point.z ), EdgePickPixels );\n\n\t\tif ( distance \u003E threshold )\n\t\t\treturn false;\n\n\t\tedge = FacePlane.Capture( hit.Body, key );\n\t\treturn true;\n\t}\n\n\tprivate void RebuildIdleBodyIdsFromFaces()\n\t{\n\t\t_idleBodyIds.Clear();\n\n\t\tforeach ( var face in _idleFaces )\n\t\t{\n\t\t\tif ( !_idleBodyIds.Contains( face.BodyId ) )\n\t\t\t\t_idleBodyIds.Add( face.BodyId );\n\t\t}\n\n\t\tforeach ( var edge in _idleEdges )\n\t\t{\n\t\t\tif ( !_idleBodyIds.Contains( edge.BodyId ) )\n\t\t\t\t_idleBodyIds.Add( edge.BodyId );\n\t\t}\n\t}\n\n\tprivate bool SameIdleEdge( EdgeRef a, EdgeRef b )\n\t{\n\t\tif ( !FacePlane.TryResolveEdge( _displayBodies, a, out var bodyA, out var keyA )\n\t\t\t|| !FacePlane.TryResolveEdge( _displayBodies, b, out var bodyB, out var keyB ) )\n\t\t\treturn false;\n\n\t\treturn bodyA.Id == bodyB.Id \u0026\u0026 keyA.Equals( keyB );\n\t}\n\n\t/// \u003Csummary\u003EMatch by resolved face, not by stored FaceRef equality \u2014 two clicks on the same\n\t/// face produce two references with slightly different hit points.\u003C/summary\u003E\n\tprivate bool SameIdleFace( FaceRef a, FaceRef b )\n\t{\n\t\tif ( !FacePlane.TryResolveFace( _displayBodies, a, out var bodyA, out var indexA )\n\t\t\t|| !FacePlane.TryResolveFace( _displayBodies, b, out var bodyB, out var indexB ) )\n\t\t\treturn false;\n\n\t\treturn bodyA.Id == bodyB.Id \u0026\u0026 indexA == indexB;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Features/SculptFeature.cs","FileName":"SculptFeature.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System.Collections.Generic;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// A sculpt in the feature tree.\n///\n/// It consumes one body the way ShellFeature does and replaces its mesh with the sculpted one, so\n/// everything downstream \u2014 export, rigging, the boolean \u2014 sees the finished surface and does not\n/// need to know a sculpt happened.\n///\n/// WHERE IT GOES IN THE HISTORY: on the cage, in place of a Subdivide. The levels ARE the\n/// subdivision, and putting a Subdivide underneath would hand this feature a dense mesh as its cage\n/// and give up the thing the whole design is for \u2014 a coarse cage you can still edit parametrically.\n///\n/// ITS PARAMETERS ARE NOT PARAMETERS. Every other feature in this tree is a handful of numbers, and\n/// the generic dialog renders them from \u003Csee cref=\u0022Parameters\u0022/\u003E. This one\u0027s state is megabytes of\n/// per-vertex deltas: it belongs to a brush, not to a text box, and it goes to a side-car blob\n/// rather than into the document. That is why \u003Csee cref=\u0022_sculpt\u0022/\u003E is private \u2014 StudioDocument\n/// saves PUBLIC fields by reflection and throws on anything it cannot write, so a public one here\n/// would either break every save or quietly serialise a megabyte of decimal digits into a format\n/// whose whole virtue is being readable. Persistence goes through\n/// \u003Csee cref=\u0022SaveDeltas\u0022/\u003E/\u003Csee cref=\u0022LoadDeltas\u0022/\u003E and \u003Csee cref=\u0022SculptSidecar\u0022/\u003E, and there is a\n/// test that the round trip actually carries the sculpt \u2014 the reflection sweep in DocumentTests\n/// cannot cover this one, so something else has to.\n///\n/// WHAT IT OUTPUTS is the top level, always. \u003Csee cref=\u0022MultiresSculpt.ViewLevel\u0022/\u003E is an editing\n/// convenience and deliberately does not reach the model: dropping to L1 to work coarsely must not\n/// quietly export an L1 model. Blender draws the same line as separate viewport and render levels,\n/// and if this ever needs the cheaper preview it should be that pair rather than one level doing\n/// both jobs.\n/// \u003C/summary\u003E\npublic sealed class SculptFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Sculpt\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Body\u0022 );\n\n\t/// \u003Csummary\u003E\n\t/// When the cage\u0027s topology changes, resample the sculpt onto the new one instead of refusing.\n\t///\n\t/// OFF BY DEFAULT, AND THAT IS THE IMPORTANT PART. Refusing is right nearly always: the usual\n\t/// cause of a changed cage is an edit somebody did not mean, and the refusal keeps the deltas\n\t/// so undoing it brings the sculpt back exactly. Reprojection is lossy and cannot be undone by\n\t/// undoing the upstream edit \u2014 the original deltas are gone once it has run. So it is a thing\n\t/// you turn on having decided the edit was deliberate, not a thing that quietly happens.\n\t/// \u003C/summary\u003E\n\tpublic readonly BoolParam Reproject = new( \u0022Reproject if the cage changes\u0022, false );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E new IParam[] { Bodies, Reproject };\n\n\tMultiresSculpt _sculpt;\n\n\t// Bytes read from a side-car, waiting for a cage. A blob cannot become a sculpt without one, and\n\t// the cage does not exist until the features above this have run, so loading is finished by the\n\t// first rebuild rather than at load time.\n\tbyte[] _pending;\n\n\t/// \u003Csummary\u003EThe levels and their deltas, once a rebuild has given them a cage. Null before that.\u003C/summary\u003E\n\tpublic MultiresSculpt Sculpt =\u003E _sculpt;\n\n\t/// \u003Csummary\u003EWhether this feature is carrying deltas that have not been placed on a cage yet.\u003C/summary\u003E\n\tpublic bool HasPendingDeltas =\u003E _pending is not null;\n\n\t// The sculpt revision this feature last built geometry from. A brush mutates the levels through\n\t// Sculpt, nowhere near the studio, so nothing calls MarkDirty and the rebuild would happily reuse\n\t// the cached body from before the stroke - the model would stop following the brush, which reads\n\t// as \u0022the sculpt tool does nothing\u0022 rather than as a caching bug.\n\tint _builtRevision = -1;\n\n\t/// \u003Csummary\u003ETrue once the levels have been changed since the last rebuild. See Feature.IsStale.\u003C/summary\u003E\n\tpublic override bool IsStale =\u003E _pending is not null || (_sculpt is not null \u0026\u0026 _sculpt.Revision != _builtRevision);\n\n\t/// \u003Csummary\u003EThis sculpt as bytes, or null if there is nothing to save yet.\u003C/summary\u003E\n\tpublic byte[] SaveDeltas() =\u003E _sculpt is null ? _pending : SculptBlob.Write( _sculpt );\n\n\t/// \u003Csummary\u003ETake bytes from a side-car. They are read at the next rebuild, not now.\u003C/summary\u003E\n\tpublic void LoadDeltas( byte[] blob ) =\u003E _pending = blob;\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tvar targets = RequireBodies( ctx, Bodies );\n\n\t\tif ( targets.Count != 1 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022A sculpt works on one body at a time\u0022,\n\t\t\t\t$\u0022This feature\u0027s selection matches {targets.Count} bodies. Deltas are stored per vertex \u0022\n\t\t\t\t\u002B \u0022against one cage, so there is no meaning to spreading them over several.\u0022,\n\t\t\t\t\u0022Pick a single body in the selection\u0022 );\n\t\t}\n\n\t\tvar body = targets[0];\n\n\t\tif ( _pending is not null )\n\t\t{\n\t\t\t// Kept on failure, never dropped. A cage that stopped matching is usually one edit\n\t\t\t// upstream from matching again, and throwing the deltas away would make that unrecoverable.\n\t\t\tMultiresSculpt loaded;\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\tloaded = SculptBlob.Read( _pending, body.Mesh );\n\t\t\t}\n\t\t\tcatch ( System.Exception e )\n\t\t\t{\n\t\t\t\tFail(\n\t\t\t\t\t\u0022This sculpt does not fit the body underneath it\u0022,\n\t\t\t\t\te.Message,\n\t\t\t\t\t\u0022Undo the edit that changed the cage\u0027s topology\u0022,\n\t\t\t\t\t\u0022Delete this feature to start a new sculpt on the current cage\u0022 );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\t_sculpt = loaded;\n\t\t\t_pending = null;\n\t\t}\n\t\telse if ( _sculpt is null )\n\t\t{\n\t\t\t_sculpt = new MultiresSculpt( body.Mesh );\n\t\t}\n\t\telse if ( !_sculpt.CanRebase( body.Mesh, out var why ) )\n\t\t{\n\t\t\tif ( !Reproject.Value )\n\t\t\t{\n\t\t\t\t// The deltas are untouched by this \u2014 SetCage is never reached, so undoing the upstream\n\t\t\t\t// edit brings the sculpt back exactly.\n\t\t\t\tFail(\n\t\t\t\t\t\u0022The cage under this sculpt changed shape\u0022,\n\t\t\t\t\twhy,\n\t\t\t\t\t\u0022Undo the edit that changed the cage\u0027s topology\u0022,\n\t\t\t\t\t\u0022Turn on \\\u0022Reproject if the cage changes\\\u0022 to resample the sculpt onto it, losing detail\u0022,\n\t\t\t\t\t\u0022Delete this feature to start a new sculpt on the current cage\u0022 );\n\t\t\t}\n\n\t\t\t_sculpt = SculptReprojection.Reproject( _sculpt, body.Mesh, out var report );\n\n\t\t\t// A WARNING, NOT SILENCE. The model still built, so this is not an error \u2014 but what came\n\t\t\t// out is an approximation of what was there, the original deltas are gone, and undoing\n\t\t\t// the upstream edit will not bring them back. Saying nothing here would make a lossy\n\t\t\t// step indistinguishable from a lossless one.\n\t\t\tWarn(\n\t\t\t\t\u0022The sculpt was resampled onto a new cage\u0022,\n\t\t\t\t$\u0022{why} It was reprojected instead: {report}.\u0022,\n\t\t\t\t\u0022Detail finer than the new cage cannot be recovered\u0022,\n\t\t\t\t\u0022The level structure is gone \u2014 everything landed in the top level\u0022,\n\t\t\t\t\u0022Undo now if this was not the intention; the original deltas are no longer held\u0022 );\n\t\t}\n\t\telse\n\t\t{\n\t\t\t_sculpt.SetCage( body.Mesh );\n\t\t}\n\n\t\tbody.Mesh = _sculpt.Evaluate( _sculpt.TopLevel );\n\n\t\t// Last, so that a failure above leaves the feature stale and the next rebuild tries again.\n\t\t_builtRevision = _sculpt.Revision;\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Effigy/Features/SolidFeatures.cs","FileName":"SolidFeatures.cs","PackageType":"library","CodeKind":"Game","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Effigy;\n\n// Features that reshape solids after they exist, whatever made them - a primitive, an extrude, a\n// revolve. Kept apart from SketchFeatures deliberately: these care about meshes, not about where\n// the mesh came from, and separating them keeps two people working on the two halves out of each\n// other\u0027s way.\n\n/// \u003Csummary\u003E\n/// Hollow the selected bodies to a wall thickness. The \u0022make a room\u0022 feature.\n///\n/// OpenFaces is an index list because there is no face selection in the kernel yet \u2014 a viewport\n/// would set it by clicking. Left empty, the result is a sealed hollow solid, which is what you\n/// want for something that only needs to be light rather than enterable.\n/// \u003C/summary\u003E\npublic sealed class ShellFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Shell\u0022;\n\n\t/// \u003Csummary\u003EA picked face becomes an opening; a picked part is what gets hollowed.\u003C/summary\u003E\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Face | GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly FloatParam Thickness = new( \u0022Wall thickness\u0022, 0.1f, 0.0001f, unit: \u0022u\u0022 );\n\n\t/// \u003Csummary\u003E\n\t/// Face indices to leave open. Not an IParam \u2014 a viewport sets this by picking, and a numeric\n\t/// list in a dialog would be unusable.\n\t///\n\t/// These indices are applied to EVERY selected body, which only makes sense when one body is\n\t/// selected. That is the normal case for a room, and the alternative \u2014 per-body face sets \u2014\n\t/// needs a selection model the kernel does not have yet.\n\t/// \u003C/summary\u003E\n\tpublic readonly List\u003Cint\u003E OpenFaces = new();\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E new IParam[] { Bodies, Thickness };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tvar targets = RequireBodies( ctx, Bodies );\n\n\t\t// Shell everything before assigning anything. Feature.Run promises that a failed feature\n\t\t// leaves the bodies as they were, and mutating in place breaks that promise the moment the\n\t\t// third body of four throws \u2014 you get a half-shelled model and an error message.\n\t\tvar shelled = new List\u003CPolyMesh\u003E( targets.Count );\n\n\t\tforeach ( var body in targets )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tshelled.Add( ShellOperation.Shell( body.Mesh, Thickness.Clamped, OpenFaces ) );\n\t\t\t}\n\t\t\tcatch ( ArgumentOutOfRangeException e )\n\t\t\t{\n\t\t\t\tFail(\n\t\t\t\t\t\u0022An opening names a face that is not on this body\u0022,\n\t\t\t\t\te.Message,\n\t\t\t\t\t\u0022Pick faces that exist on the selected body\u0022 );\n\t\t\t}\n\t\t\tcatch ( InvalidOperationException e )\n\t\t\t{\n\t\t\t\tRefuseShell( e.Message, body.Mesh );\n\t\t\t}\n\t\t}\n\n\t\tfor ( var i = 0; i \u003C targets.Count; i\u002B\u002B )\n\t\t\ttargets[i].Mesh = shelled[i];\n\t}\n\n\tvoid RefuseShell( string message, PolyMesh mesh )\n\t{\n\t\tif ( message.Contains( \u0022pinch\u0022 ) )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022The opened faces pinch to a point\u0022,\n\t\t\t\tmessage,\n\t\t\t\t\u0022Open faces that share an edge\u0022,\n\t\t\t\t\u0022Leave a face between the openings\u0022 );\n\t\t}\n\n\t\tif ( message.Contains( \u0022every face\u0022 ) )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022Cannot open every face \u2014 there would be nothing left\u0022,\n\t\t\t\t\u0022Every face was marked as an opening, so the shell has no wall to keep.\u0022,\n\t\t\t\t\u0022Leave at least one face closed\u0022 );\n\t\t}\n\n\t\tif ( message.Contains( \u0022open mesh\u0022 ) )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022Cannot shell an open mesh\u0022,\n\t\t\t\tmessage,\n\t\t\t\t\u0022Close the mesh first\u0022 );\n\t\t}\n\n\t\tvar fit = SuggestThickness( mesh, Thickness.Clamped, OpenFaces );\n\n\t\tif ( fit \u003E 0f )\n\t\t{\n\t\t\tvar suggestion = FloorThousandths( fit );\n\t\t\tFailOn( \u0022Wall thickness\u0022, suggestion,\n\t\t\t\t\u0022This wall thickness does not fit this part\u0022,\n\t\t\t\tmessage,\n\t\t\t\t$\u0022Reduce wall thickness to {suggestion:0.###}\u0022,\n\t\t\t\t\u0022Open a face so the offset has room to move\u0022 );\n\t\t}\n\n\t\tFailOn( \u0022Wall thickness\u0022,\n\t\t\t\u0022This shell cannot be built\u0022,\n\t\t\tmessage,\n\t\t\t\u0022Reduce the wall thickness\u0022,\n\t\t\t\u0022Open a face so the offset has room to move\u0022 );\n\t}\n\n\tstatic float SuggestThickness( PolyMesh mesh, float size, List\u003Cint\u003E open )\n\t{\n\t\tif ( size \u003C= 0f )\n\t\t\treturn 0f;\n\n\t\tif ( Fits( mesh, size, open ) )\n\t\t\treturn size;\n\n\t\tvar lo = 0.0001f;\n\t\tvar hi = size;\n\n\t\tif ( !Fits( mesh, lo, open ) )\n\t\t\treturn 0f;\n\n\t\tfor ( var i = 0; i \u003C 12; i\u002B\u002B )\n\t\t{\n\t\t\tvar mid = ( lo \u002B hi ) * 0.5f;\n\n\t\t\tif ( Fits( mesh, mid, open ) )\n\t\t\t\tlo = mid;\n\t\t\telse\n\t\t\t\thi = mid;\n\t\t}\n\n\t\treturn lo;\n\t}\n\n\tstatic bool Fits( PolyMesh mesh, float thickness, List\u003Cint\u003E open )\n\t{\n\t\ttry\n\t\t{\n\t\t\tShellOperation.Shell( mesh, thickness, open );\n\t\t\treturn true;\n\t\t}\n\t\tcatch ( InvalidOperationException )\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t\tcatch ( ArgumentOutOfRangeException )\n\t\t{\n\t\t\treturn false;\n\t\t}\n\t}\n}\n\n/// \u003Csummary\u003E\n/// Flat chamfer along every edge sharper than the angle threshold \u2014 Onshape\u0027s Chamfer.\n///\n/// THE FIELD IS STILL CALLED \u0060Width\u0060 AND THE LABEL IS \u0022Distance\u0022. Those disagree on purpose. The\n/// label is what Onshape calls the dimension and what anyone reading the dialog expects; the field\n/// name is the key StudioDocument writes into a saved file, so renaming it would silently drop the\n/// distance out of every document already on disk. A stale field name costs one comment. See\n/// StudioDocument.StateFields.\n/// \u003C/summary\u003E\npublic sealed class ChamferFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Chamfer\u0022;\n\n\t/// \u003Csummary\u003EEdges are what it blends, and a picked FACE means its boundary - \u0022select the top,\n\t/// then Fillet\u0022 - so both count. See Feature.ApplyBlendEdges.\u003C/summary\u003E\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Edge | GeometryKind.Face | GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly FloatParam Width = new( \u0022Distance\u0022, 0.1f, 0.0001f, unit: \u0022u\u0022 );\n\tpublic readonly FloatParam AngleThreshold = new( \u0022Angle threshold\u0022, 15f, 0f, 180f, unit: \u0022deg\u0022 );\n\n\t/// \u003Csummary\u003EEdges to cut. Empty means every edge sharper than the angle threshold, which is\n\t/// how this feature behaved before edges could be picked and how a Fillet with no selection\n\t/// still should.\u003C/summary\u003E\n\tpublic List\u003CEdgeRef\u003E Edges = new();\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E new IParam[] { Bodies, Width, AngleThreshold };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tCommitBlend( BlendBodies( ctx, Edges, ( mesh, keys ) =\u003E\n\t\t\tkeys is null\n\t\t\t\t? EdgeBlend.ChamferReport( mesh, Width.Clamped, AngleThreshold.Clamped )\n\t\t\t\t: EdgeBlend.ChamferReport( mesh, Width.Clamped, keys ) ), \u0022Distance\u0022 );\n\t}\n}\n\n/// \u003Csummary\u003E\n/// Rounded fillet along every edge sharper than the angle threshold \u2014 Onshape\u0027s Fillet.\n///\n/// A SEPARATE FEATURE RATHER THAN A CHAMFER WITH SEGMENTS TURNED UP, because that is what it is to\n/// the person using it, and because the dimension means something different: a chamfer\u0027s distance\n/// is measured back along each face, a fillet\u0027s radius is the arc\u0027s own radius and the setback\n/// follows from the angle the edge opens at. One control that means two things depending on\n/// another control is the shape of a bad dialog. See EdgeBlend for the one algorithm underneath.\n///\n/// \u0060Segments\u0060 has no Onshape counterpart because Onshape is a B-rep and stores the arc exactly.\n/// This kernel is polygonal, so how finely the arc is cut into faces is a real authoring decision\n/// and belongs in the dialog.\n/// \u003C/summary\u003E\npublic sealed class FilletFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Fillet\u0022;\n\n\t/// \u003Csummary\u003EEdges are what it blends, and a picked FACE means its boundary - \u0022select the top,\n\t/// then Fillet\u0022 - so both count. See Feature.ApplyBlendEdges.\u003C/summary\u003E\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Edge | GeometryKind.Face | GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly FloatParam Radius = new( \u0022Radius\u0022, 0.1f, 0.0001f, unit: \u0022u\u0022 );\n\tpublic readonly IntParam Segments = new( \u0022Segments\u0022, 4, 1, 16 );\n\tpublic readonly FloatParam AngleThreshold = new( \u0022Angle threshold\u0022, 15f, 0f, 180f, unit: \u0022deg\u0022 );\n\n\t/// \u003Csummary\u003EEdges to round. Empty means every edge sharper than the angle threshold \u2014 see\n\t/// ChamferFeature.Edges.\u003C/summary\u003E\n\tpublic List\u003CEdgeRef\u003E Edges = new();\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E\n\t\tnew IParam[] { Bodies, Radius, Segments, AngleThreshold };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tCommitBlend( BlendBodies( ctx, Edges, ( mesh, keys ) =\u003E\n\t\t\tkeys is null\n\t\t\t\t? EdgeBlend.FilletReport( mesh, Radius.Clamped, AngleThreshold.Clamped, Segments.Clamped )\n\t\t\t\t: EdgeBlend.FilletReport( mesh, Radius.Clamped, Segments.Clamped, keys ) ), \u0022Radius\u0022 );\n\t}\n}\n\n/// \u003Csummary\u003E\n/// Re-project UVs across the selected bodies. Onshape has no equivalent because it does not care\n/// about textures; every game-facing modeller needs one.\n///\n/// Placed as a feature rather than an export option on purpose: where it sits in the tree decides\n/// what it sees. Before a bevel it projects the sharp cage and the chamfer strips inherit\n/// interpolated UVs; after a bevel it projects the chamfers as their own faces.\n/// \u003C/summary\u003E\npublic sealed class UVProjectFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022UV project\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly ChoiceParam Mode = new( \u0022Mode\u0022, new[] { \u0022Box\u0022, \u0022Planar\u0022, \u0022Unwrap\u0022 } );\n\tpublic readonly Vec3Param Direction = new( \u0022Direction\u0022, new Vec3( 0, 0, 1 ) );\n\tpublic readonly FloatParam Scale = new( \u0022Units per tile\u0022, 1f, 0.0001f, unit: \u0022u\u0022 );\n\n\t/// \u003Csummary\u003EHow far a face may lean from its chart before it starts a new one. Only Unwrap has\n\t/// anything to say about it.\u003C/summary\u003E\n\tpublic readonly FloatParam ChartAngle = new( \u0022Chart angle\u0022, 66f, 1f, 179f, unit: \u0022deg\u0022 );\n\n\t/// \u003Csummary\u003EGutter between islands. The bake bleeds its islands outward so seams do not glow\n\t/// under mipmapping, and without a gutter that bleed runs into the neighbour.\u003C/summary\u003E\n\tpublic readonly FloatParam Margin = new( \u0022Island margin\u0022, 0.01f, 0f, 0.2f );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E Mode.Value switch\n\t{\n\t\t\u0022Planar\u0022 =\u003E new IParam[] { Bodies, Mode, Direction, Scale },\n\t\t\u0022Unwrap\u0022 =\u003E new IParam[] { Bodies, Mode, ChartAngle, Margin },\n\t\t_ =\u003E new IParam[] { Bodies, Mode, Scale },\n\t};\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Mode.Value == \u0022Planar\u0022 \u0026\u0026 Direction.Value.LengthSquared \u003C 1e-12f )\n\t\t{\n\t\t\tFailOn( \u0022Direction\u0022,\n\t\t\t\t\u0022Projection direction has no length\u0022,\n\t\t\t\t\u0022A planar projection needs a direction, and this one is (0, 0, 0).\u0022,\n\t\t\t\t\u0022Set Direction to the axis you want the texture to face\u0022 );\n\t\t}\n\n\t\tforeach ( var body in RequireBodies( ctx, Bodies ) )\n\t\t{\n\t\t\t// Unwrap is the only one of the three a BAKE can use. Box and planar tile on purpose and\n\t\t\t// overlap by construction, which is right for a texture and useless for a normal map -\n\t\t\t// see UVUnwrap and NormalBake.Measure.\n\t\t\tif ( Mode.Value == \u0022Unwrap\u0022 )\n\t\t\t{\n\t\t\t\tvar report = UVUnwrap.Unwrap( body.Mesh, ChartAngle.Clamped, Margin.Clamped );\n\n\t\t\t\tif ( report.SkippedFaces \u003E 0 )\n\t\t\t\t{\n\t\t\t\t\tWarn(\n\t\t\t\t\t\t\u0022Some faces could not be unwrapped\u0022,\n\t\t\t\t\t\t$\u0022{report}. A face with no area has no direction to flatten onto.\u0022,\n\t\t\t\t\t\t\u0022Check the body for degenerate faces\u0022 );\n\t\t\t\t}\n\t\t\t}\n\t\t\telse if ( Mode.Value == \u0022Planar\u0022 )\n\t\t\t{\n\t\t\t\tUVProjection.PlanarProject( body.Mesh, Direction.Value, Scale.Clamped );\n\t\t\t}\n\t\t\telse\n\t\t\t{\n\t\t\t\tUVProjection.BoxProject( body.Mesh, Scale.Clamped );\n\t\t\t}\n\t\t}\n\t}\n}\n\n\n/// \u003Csummary\u003E\n/// Assigns a material slot to picked faces.\n///\n/// Faces have carried a material slot since the beginning and every exporter groups by it \u2014 OBJ\n/// writes usemtl, SMD and DMX name a material per face \u2014 so a model has always been able to arrive\n/// in ModelDoc with several slots to bind. What was missing was any way to say which faces. Extrude\n/// puts its whole solid on one slot and nothing could change it afterwards, so in practice every\n/// model was single-material whatever the exporters were prepared to do.\n///\n/// A FEATURE, NOT AN EDIT. Painting the mesh directly would be undone by the next rebuild, since\n/// bodies are rebuilt from scratch every time. Sitting in the tree means the assignment is re-applied\n/// after the geometry it paints is remade, and it can be rolled back, suppressed and reordered like\n/// anything else.\n///\n/// Faces are held as FaceRefs, so the reference survives the rebuild that recreates them \u2014 the same\n/// machinery a sketch drawn on a face uses, resolved through the same function so the two cannot\n/// disagree about which face is meant.\n/// \u003C/summary\u003E\n/// \u003Csummary\u003E\n/// Drill a hole where a face was picked.\n///\n/// CONVENIENCE, NOT CAPABILITY, and worth saying because it decides what belongs here. Holes already\n/// work as inner loops of a profile and cuts already work through MeshBoolean; what was missing was\n/// that nobody wants to draw two concentric circles and extrude them when the numbers they have are\n/// \u00226mm clearance, 10mm head, 6 deep\u0022. So this is a parameterised shape and a dialog, and the shape\n/// itself lives in HoleOperation.\n///\n/// IT NEEDS A BOOLEAN PROVIDER, like every other cut. Taking material away means recomputing the\n/// surface, and the engine does that inside the s\u0026box editor. Headless, the suite installs a stub -\n/// see MergeTests - so this feature can still be tested end to end without one.\n/// \u003C/summary\u003E\npublic sealed class HoleFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Hole\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Face;\n\n\t/// \u003Csummary\u003EWhere the holes go. One per picked face, drilled along that face\u0027s own normal, so a\n\t/// hole rides its face the way a sketch does.\u003C/summary\u003E\n\tpublic List\u003CFaceRef\u003E Faces = new();\n\n\tpublic readonly ChoiceParam Style = new( \u0022Style\u0022, new[] { \u0022Simple\u0022, \u0022Counterbore\u0022, \u0022Countersink\u0022 } );\n\tpublic readonly FloatParam Diameter = new( \u0022Diameter\u0022, 0.25f, 0.0001f, unit: \u0022u\u0022 );\n\n\t/// \u003Csummary\u003EZero means through everything. A through hole is built long enough to leave the body\n\t/// either side, because a tool that stops exactly at the far surface gives the boolean two\n\t/// coplanar faces and those are the ones that produce slivers.\u003C/summary\u003E\n\tpublic readonly FloatParam Depth = new( \u0022Depth (0 = through)\u0022, 0f, 0f, unit: \u0022u\u0022 );\n\n\tpublic readonly FloatParam HeadDiameter = new( \u0022Head diameter\u0022, 0.5f, 0.0001f, unit: \u0022u\u0022 );\n\tpublic readonly FloatParam HeadDepth = new( \u0022Head depth\u0022, 0.15f, 0.0001f, unit: \u0022u\u0022 );\n\tpublic readonly FloatParam SinkAngle = new( \u0022Countersink angle\u0022, 90f, 1f, 179f, unit: \u0022deg\u0022 );\n\tpublic readonly IntParam Segments = new( \u0022Segments\u0022, 24, 6, 256 );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E Style.Value switch\n\t{\n\t\t\u0022Counterbore\u0022 =\u003E new IParam[] { Style, Diameter, Depth, HeadDiameter, HeadDepth, Segments },\n\t\t\u0022Countersink\u0022 =\u003E new IParam[] { Style, Diameter, Depth, HeadDiameter, SinkAngle, Segments },\n\t\t_ =\u003E new IParam[] { Style, Diameter, Depth, Segments },\n\t};\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Faces.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022No faces picked - click where the holes should go\u0022,\n\t\t\t\t\u0022A hole is drilled into a face along that face\u0027s own normal, and none have been chosen yet.\u0022,\n\t\t\t\t\u0022Click a face in the viewport\u0022 );\n\t\t}\n\n\t\tif ( !MeshBoolean.Available )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022Drilling a hole needs the engine\u0027s boolean\u0022,\n\t\t\t\t\u0022Taking material away means recomputing the surface, and no boolean provider is installed.\u0022,\n\t\t\t\t\u0022Run this inside the s\u0026box editor, where the provider is available\u0022 );\n\t\t}\n\n\t\tvar style = Style.Index switch\n\t\t{\n\t\t\t1 =\u003E HoleStyle.Counterbore,\n\t\t\t2 =\u003E HoleStyle.Countersink,\n\t\t\t_ =\u003E HoleStyle.Simple,\n\t\t};\n\n\t\tvar drilled = 0;\n\t\tvar lost = 0;\n\t\tvar separated = 0;\n\t\tvar separatedBodies = new List\u003Cstring\u003E();\n\n\t\tforeach ( var reference in Faces )\n\t\t{\n\t\t\tif ( !FacePlane.TryResolveFace( ctx.Bodies, reference, out var body, out var faceIndex ) )\n\t\t\t{\n\t\t\t\tlost\u002B\u002B;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tvar mesh = body.Mesh;\n\t\t\tvar face = mesh.Faces[faceIndex];\n\t\t\tvar normal = mesh.FaceNormal( face );\n\n\t\t\tif ( normal.LengthSquared \u003C 1e-16f )\n\t\t\t{\n\t\t\t\tlost\u002B\u002B;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// INTO the solid, which is against the face\u0027s outward normal. Drilling along it would put\n\t\t\t// the tool entirely outside the body and cut nothing at all - and a boolean that removes\n\t\t\t// nothing succeeds, so this would look like the feature quietly not working.\n\t\t\tvar into = -normal.Normal;\n\t\t\tvar at = reference.Point;\n\n\t\t\tPolyMesh tool;\n\n\t\t\ttry\n\t\t\t{\n\t\t\t\ttool = HoleOperation.Build( style, at, into, Diameter.Clamped, Depth.Clamped,\n\t\t\t\t\tHeadDiameter.Clamped, HeadDepth.Clamped, SinkAngle.Clamped,\n\t\t\t\t\tbody.Mesh.BoundsDiagonal, Segments.Clamped );\n\t\t\t}\n\t\t\tcatch ( InvalidOperationException e )\n\t\t\t{\n\t\t\t\tFail( \u0022This hole cannot be built\u0022, e.Message,\n\t\t\t\t\t\u0022Make the head wider than the shaft\u0022,\n\t\t\t\t\t\u0022Check the diameter and depth\u0022 );\n\t\t\t\treturn;\n\t\t\t}\n\n\t\t\tbody.Mesh = MeshBoolean.Apply( BooleanOp.Subtract, body.Mesh, tool );\n\t\t\tdrilled\u002B\u002B;\n\n\t\t\t// A hole drilled right through a thin wall can sever the part - a slot across a bar is\n\t\t\t// the obvious one. See Feature.SeparatePieces.\n\t\t\tvar added = SeparatePieces( ctx, body );\n\n\t\t\tif ( added \u003E 0 )\n\t\t\t{\n\t\t\t\tseparated \u002B= added;\n\n\t\t\t\tif ( !separatedBodies.Contains( body.Name ) )\n\t\t\t\t\tseparatedBodies.Add( body.Name );\n\t\t\t}\n\t\t}\n\n\t\tif ( drilled == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022None of the picked faces are on the model any more\u0022,\n\t\t\t\t$\u0022All {Faces.Count} of them named geometry the features above this one no longer produce.\u0022,\n\t\t\t\t\u0022Pick the faces again on the current model\u0022 );\n\t\t}\n\n\t\t// ONE DIAGNOSTIC PER FEATURE, so the two possible warnings cannot both be set and the second\n\t\t// would silently overwrite the first. Lost faces are the one to lead with - they mean the\n\t\t// feature did less than it was asked - and a separation that happened alongside is named in\n\t\t// the same message rather than dropped.\n\t\tif ( lost \u003E 0 )\n\t\t{\n\t\t\tvar also = separated \u003E 0\n\t\t\t\t? $\u0022 Drilling also separated {Listed( separatedBodies )} into {separated} more part(s).\u0022\n\t\t\t\t: \u0022\u0022;\n\n\t\t\tWarn(\n\t\t\t\t$\u0022{lost} of {Faces.Count} picked faces are no longer on the model\u0022,\n\t\t\t\t\u0022They named geometry the features above this one no longer produce, so they were skipped.\u0022 \u002B also,\n\t\t\t\t\u0022Pick them again on the current model\u0022 );\n\n\t\t\treturn;\n\t\t}\n\n\t\tif ( separatedBodies.Count == 1 )\n\t\t{\n\t\t\tWarnSeparated( separated, separatedBodies[0] );\n\t\t\treturn;\n\t\t}\n\n\t\tif ( separatedBodies.Count \u003E 1 )\n\t\t{\n\t\t\tWarn(\n\t\t\t\t$\u0022Drilling separated {separatedBodies.Count} parts\u0022,\n\t\t\t\t$\u0022{Listed( separatedBodies )} each went all the way through, adding {separated} part(s) to the studio. \u0022\n\t\t\t\t\t\u002B \u0022Each original keeps its name and id and its largest piece.\u0022,\n\t\t\t\t\u0022Reduce the depth if these were meant to be pockets\u0022,\n\t\t\t\t\u0022Nothing to fix if separating the parts was the intent\u0022 );\n\t\t}\n\t}\n}\n\n/// \u003Csummary\u003E\n/// Move picked faces of a solid that already exists \u2014 push and pull on a finished part.\n///\n/// THE FEATURE THAT CLOSES THE \u0022edit it afterwards\u0022 GAP. Every other tool here builds forwards: a\n/// sketch becomes a prism, the prism gets a fillet, and changing the prism means going back to the\n/// sketch. This one takes a face of whatever is on screen and moves it, with the walls around it\n/// following, and it does not care whether that face came from a primitive, an extrude or a boolean.\n///\n/// TWO MODES, and the difference only shows on a pair of facing walls:\n///\n/// - **Offset** moves each face along its own normal, so a facing pair moves APART \u2014 the wall gets\n///   thicker or thinner.\n/// - **Translate** moves them all one way, so a facing pair SLIDES and keeps its thickness. That is\n///   the one you want when a wall is in the wrong place rather than the wrong size.\n///\n/// See FaceMove for the solve, for why the two modes are one code path, and for the list of things\n/// this refuses rather than approximating.\n///\n/// IT LIVES IN THE FEATURE TREE LIKE EVERYTHING ELSE, and that is the whole bet of direct editing\n/// here: the faces are held as FaceRefs, so an upstream edit that destroys one makes this feature\n/// say so out loud rather than reattaching itself to whatever is nearest. See FacePlane\u0027s header for\n/// why the reference is geometric rather than an index, and DraftFeature for the same contract.\n/// \u003C/summary\u003E\npublic sealed class MoveFaceFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Move face\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Face;\n\n\t/// \u003Csummary\u003EThe faces to move. Not an IParam, for the reason FaceMaterialFeature gives: a list\n\t/// of picked geometry has no generic control to render it.\u003C/summary\u003E\n\tpublic List\u003CFaceRef\u003E Faces = new();\n\n\t/// \u003Csummary\u003E\n\t/// Offset first because it is the one that needs no second answer \u2014 a distance and nothing else,\n\t/// which is what \u0022push this face out a bit\u0022 means. Translate needs a direction as well.\n\t/// \u003C/summary\u003E\n\tpublic readonly ChoiceParam Mode = new( \u0022Mode\u0022, new[] { \u0022Offset\u0022, \u0022Translate\u0022 } );\n\n\tpublic const int ModeOffset = 0;\n\tpublic const int ModeTranslate = 1;\n\n\t/// \u003Csummary\u003EHow far. NEGATIVE IS ALLOWED and is half the point: pushing a face into the solid is\n\t/// the same operation as pulling it out, and making that a separate direction to type would be\n\t/// two controls for one number.\u003C/summary\u003E\n\tpublic readonly FloatParam Distance = new( \u0022Distance\u0022, 0.25f, unit: \u0022u\u0022 );\n\n\t/// \u003Csummary\u003EWhich way, in Translate mode. Ignored in Offset mode, where each face has a\n\t/// direction of its own already.\u003C/summary\u003E\n\tpublic readonly Vec3Param Direction = new( \u0022Direction\u0022, new Vec3( 0, 0, 1 ) );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E Mode.Index == ModeTranslate\n\t\t? new IParam[] { Mode, Distance, Direction }\n\t\t: new IParam[] { Mode, Distance };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Faces.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022No faces picked - click the faces to move\u0022,\n\t\t\t\t\u0022This moves faces of a part that already exists, and none have been chosen yet.\u0022,\n\t\t\t\t\u0022Click a face of the part in the viewport, then press Move Face\u0022 );\n\t\t}\n\n\t\tif ( Mode.Index == ModeTranslate \u0026\u0026 Direction.Value.LengthSquared \u003C 1e-12f )\n\t\t{\n\t\t\tFailOn( \u0022Direction\u0022,\n\t\t\t\t\u0022The direction has no length\u0022,\n\t\t\t\t\u0022Translate moves the faces along one direction, and this one is (0, 0, 0).\u0022,\n\t\t\t\t\u0022Set a direction, or switch to Offset and let each face use its own normal\u0022 );\n\t\t}\n\n\t\t// Grouped by body so one call moves every face picked on it. Moving them one at a time would\n\t\t// solve a shared corner once per face it belongs to, and the second solve would be against a\n\t\t// mesh the first had already moved - the same reason DraftFeature groups.\n\t\tvar byBody = new Dictionary\u003CBody, List\u003Cint\u003E\u003E();\n\t\tvar lost = 0;\n\n\t\tforeach ( var reference in Faces )\n\t\t{\n\t\t\tif ( !FacePlane.TryResolveFace( ctx.Bodies, reference, out var body, out var faceIndex ) )\n\t\t\t{\n\t\t\t\tlost\u002B\u002B;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( !byBody.TryGetValue( body, out var list ) )\n\t\t\t\tbyBody[body] = list = new List\u003Cint\u003E();\n\n\t\t\tif ( !list.Contains( faceIndex ) )\n\t\t\t\tlist.Add( faceIndex );\n\t\t}\n\n\t\tif ( byBody.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022None of the picked faces are on the model any more\u0022,\n\t\t\t\t$\u0022All {Faces.Count} of them named geometry that the features above this one no longer produce.\u0022,\n\t\t\t\t\u0022Pick the faces again on the current model\u0022,\n\t\t\t\t\u0022Move this feature back below the edit that changed them\u0022 );\n\t\t}\n\n\t\tvar moved = new List\u003C(Body Body, PolyMesh Mesh)\u003E();\n\n\t\t// Every body solved before anything is assigned, so a failure on the third of four leaves the\n\t\t// model as it was rather than half moved - the same promise ShellFeature and Draft make.\n\t\tforeach ( var (body, faces) in byBody )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tmoved.Add( (body, Solve( body.Mesh, faces )) );\n\t\t\t}\n\t\t\tcatch ( InvalidOperationException e )\n\t\t\t{\n\t\t\t\tFailOn( Mode.Index == ModeTranslate ? \u0022Direction\u0022 : \u0022Distance\u0022,\n\t\t\t\t\t\u0022That move cannot be made exactly\u0022,\n\t\t\t\t\te.Message,\n\t\t\t\t\t\u0022Use a smaller distance\u0022,\n\t\t\t\t\t\u0022Select the whole flat surface rather than part of it\u0022 );\n\t\t\t}\n\t\t}\n\n\t\tforeach ( var (body, mesh) in moved )\n\t\t\tbody.Mesh = mesh;\n\n\t\tif ( lost \u003E 0 )\n\t\t{\n\t\t\tWarn(\n\t\t\t\t$\u0022{lost} of {Faces.Count} picked faces are no longer on the model\u0022,\n\t\t\t\t\u0022They named geometry the features above this one no longer produce, so they were skipped.\u0022,\n\t\t\t\t\u0022Pick them again on the current model\u0022 );\n\t\t}\n\t}\n\n\tPolyMesh Solve( PolyMesh mesh, List\u003Cint\u003E faces ) =\u003E Mode.Index == ModeTranslate\n\t\t? FaceMove.Translate( mesh, faces, Direction.Value.Normal * Distance.Value )\n\t\t: FaceMove.Offset( mesh, faces, Distance.Value );\n}\n\n/// \u003Csummary\u003E\n/// Taper picked faces of a solid that already exists, so the part can leave a mould.\n///\n/// Extrude\u0027s Taper covers a face being MADE; this covers one that is already there, which by the\n/// time you need it is usually twenty features back with fillets and cuts on top of it. See\n/// DraftOperation for the method and for why a face looking straight along the pull cannot be\n/// drafted at all.\n/// \u003C/summary\u003E\npublic sealed class DraftFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Draft\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Face;\n\n\t/// \u003Csummary\u003EThe faces to taper. Not an IParam, for the reason FaceMaterialFeature gives: a list\n\t/// of picked geometry has no generic control to render it.\u003C/summary\u003E\n\tpublic List\u003CFaceRef\u003E Faces = new();\n\n\tpublic readonly Vec3Param Pull = new( \u0022Pull direction\u0022, new Vec3( 0, 0, 1 ) );\n\tpublic readonly FloatParam Angle = new( \u0022Draft angle\u0022, 3f, -88f, 88f, unit: \u0022deg\u0022 );\n\n\t/// \u003Csummary\u003E\n\t/// Where the parting line sits, measured along the pull from the origin.\n\t///\n\t/// One number rather than a point, because the plane is always perpendicular to the pull - a\n\t/// neutral plane at any other angle is not a parting line, it is two different drafts.\n\t/// \u003C/summary\u003E\n\tpublic readonly FloatParam Neutral = new( \u0022Neutral plane\u0022, 0f, unit: \u0022u\u0022 );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E new IParam[] { Pull, Angle, Neutral };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Faces.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022No faces picked - click the faces to taper\u0022,\n\t\t\t\t\u0022A draft leans faces away from a parting line, and none have been chosen yet.\u0022,\n\t\t\t\t\u0022Click the walls of the part in the viewport\u0022 );\n\t\t}\n\n\t\tif ( Pull.Value.LengthSquared \u003C 1e-12f )\n\t\t{\n\t\t\tFailOn( \u0022Pull direction\u0022,\n\t\t\t\t\u0022The pull direction has no length\u0022,\n\t\t\t\t\u0022A draft leans faces relative to the direction the part is pulled, and this one is (0, 0, 0).\u0022,\n\t\t\t\t\u0022Set the pull to the axis the mould opens along\u0022 );\n\t\t}\n\n\t\t// Grouped by body so one call drafts every face picked on it: drafting them one at a time\n\t\t// would move a shared vertex once per face it belongs to, and the corner between two drafted\n\t\t// walls would lean twice as far as either.\n\t\tvar byBody = new Dictionary\u003CBody, List\u003Cint\u003E\u003E();\n\t\tvar lost = 0;\n\n\t\tforeach ( var reference in Faces )\n\t\t{\n\t\t\tif ( !FacePlane.TryResolveFace( ctx.Bodies, reference, out var body, out var faceIndex ) )\n\t\t\t{\n\t\t\t\tlost\u002B\u002B;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( !byBody.TryGetValue( body, out var list ) )\n\t\t\t\tbyBody[body] = list = new List\u003Cint\u003E();\n\n\t\t\tif ( !list.Contains( faceIndex ) )\n\t\t\t\tlist.Add( faceIndex );\n\t\t}\n\n\t\tif ( byBody.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022None of the picked faces are on the model any more\u0022,\n\t\t\t\t$\u0022All {Faces.Count} of them named geometry that the features above this one no longer produce.\u0022,\n\t\t\t\t\u0022Pick the faces again on the current model\u0022,\n\t\t\t\t\u0022Move this feature back below the edit that changed them\u0022 );\n\t\t}\n\n\t\tvar neutral = Pull.Value.Normal * Neutral.Value;\n\t\tvar drafted = new List\u003C(Body Body, PolyMesh Mesh)\u003E();\n\n\t\t// Every body drafted before anything is assigned, so a failure on the third of four leaves\n\t\t// the model as it was rather than half tapered - the same promise ShellFeature makes.\n\t\tforeach ( var (body, faces) in byBody )\n\t\t{\n\t\t\ttry\n\t\t\t{\n\t\t\t\tdrafted.Add( (body, DraftOperation.Draft( body.Mesh, faces, neutral, Pull.Value, Angle.Clamped )) );\n\t\t\t}\n\t\t\tcatch ( InvalidOperationException e )\n\t\t\t{\n\t\t\t\tRefuseDraft( e.Message, body, faces, neutral );\n\t\t\t}\n\t\t}\n\n\t\tforeach ( var (body, mesh) in drafted )\n\t\t\tbody.Mesh = mesh;\n\n\t\tif ( lost \u003E 0 )\n\t\t{\n\t\t\tWarn(\n\t\t\t\t$\u0022{lost} of {Faces.Count} picked faces are no longer on the model\u0022,\n\t\t\t\t\u0022They named geometry the features above this one no longer produce, so they were skipped.\u0022,\n\t\t\t\t\u0022Pick them again on the current model\u0022 );\n\t\t}\n\t}\n\n\tvoid RefuseDraft( string message, Body body, List\u003Cint\u003E faces, Vec3 neutral )\n\t{\n\t\t// \u0022Inside out\u0022 and \u0022collapsed\u0022 are both an angle that is too big, and the useful answer to\n\t\t// both is the largest one that still works - measured, not guessed.\n\t\tif ( message.Contains( \u0022inside out\u0022 ) || message.Contains( \u0022collapses\u0022 ) )\n\t\t{\n\t\t\tvar largest = DraftOperation.LargestAngle( body.Mesh, faces, neutral, Pull.Value, Angle.Clamped );\n\n\t\t\tFailOn( \u0022Draft angle\u0022, largest,\n\t\t\t\t$\u0022A draft of {Angle.Clamped}deg turns this part inside out\u0022,\n\t\t\t\t$\u0022{message} The faces are not deep enough either side of the neutral plane to lean that far.\u0022,\n\t\t\t\t$\u0022Use {largest:0.##}deg or less\u0022,\n\t\t\t\t\u0022Move the neutral plane closer to the middle of the wall\u0022 );\n\t\t}\n\n\t\tif ( message.Contains( \u0022straight along the pull\u0022 ) )\n\t\t{\n\t\t\tFailOn( \u0022Pull direction\u0022,\n\t\t\t\t\u0022These faces cannot be drafted along this pull\u0022,\n\t\t\t\tmessage \u002B \u0022 A face\u0027s draft is a lean of its own normal, and a normal parallel to the pull has nothing to lean.\u0022,\n\t\t\t\t\u0022Pick the walls rather than the top and bottom\u0022,\n\t\t\t\t\u0022Set the pull to an axis the picked faces run along\u0022 );\n\t\t}\n\n\t\tFail( \u0022This draft cannot be applied\u0022, message, \u0022Try a smaller angle\u0022 );\n\t}\n}\n\npublic sealed class FaceMaterialFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Face material\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Face;\n\n\t/// \u003Csummary\u003EThe faces to paint. Not an IParam: a list of picked geometry has no generic control\n\t/// to render it, the way a float or a choice does, and the dialog builds it a selection box of\n\t/// its own.\u003C/summary\u003E\n\tpublic List\u003CFaceRef\u003E Faces = new();\n\n\tpublic readonly IntParam Material = new( \u0022Material slot\u0022, 1, 0, 63 ) { Slider = false };\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E new IParam[] { Material };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Faces.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022No faces picked \u2014 click the faces to assign this material to.\u0022,\n\t\t\t\t\u0022A face-material feature paints faces, and none have been chosen yet.\u0022,\n\t\t\t\t\u0022Click faces in the viewport to assign this material\u0022 );\n\t\t}\n\n\t\tvar painted = 0;\n\t\tvar lost = 0;\n\n\t\tforeach ( var reference in Faces )\n\t\t{\n\t\t\tif ( !FacePlane.TryResolveFace( ctx.Bodies, reference, out var body, out var faceIndex ) )\n\t\t\t{\n\t\t\t\tlost\u002B\u002B;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\t// THE WHOLE SURFACE, not the one n-gon the reference resolved to. A wall a boolean has\n\t\t\t// returned as fragments is one face to look at and one face to click, and the viewport\n\t\t\t// lights all of it - painting a single fragment would turn a third of a wall red and\n\t\t\t// leave the rest as it was, which is the highlight promising something the click does\n\t\t\t// not deliver. FaceSurface stops at a neighbour already on another slot, so this can\n\t\t\t// never eat an assignment somebody else made.\n\t\t\tvar surface = FaceSurface.FromFace( body.Mesh, faceIndex );\n\n\t\t\tif ( surface.IsEmpty )\n\t\t\t\tbody.Mesh.Faces[faceIndex].Material = Material.Clamped;\n\t\t\telse\n\t\t\t\tforeach ( var index in surface.Faces )\n\t\t\t\t\tbody.Mesh.Faces[index].Material = Material.Clamped;\n\n\t\t\tpainted\u002B\u002B;\n\t\t}\n\n\t\t// Losing SOME faces is a warning: an upstream edit that removes one face out of twelve is\n\t\t// ordinary, and failing the feature over it would blank the other eleven. Losing ALL of them\n\t\t// means the geometry moved out from under the whole assignment, which is worth stopping for\n\t\t// rather than leaving a feature in the tree that silently does nothing.\n\t\tif ( painted == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t$\u0022None of the {Faces.Count} picked face(s) still exist \u2014 the geometry changed underneath them. Pick them again.\u0022,\n\t\t\t\t$\u0022All {Faces.Count} stored face(s) failed to resolve against the bodies as they are now.\u0022,\n\t\t\t\t\u0022Pick the faces again on the current geometry\u0022 );\n\t\t}\n\n\t\tif ( lost \u003E 0 )\n\t\t{\n\t\t\tWarn(\n\t\t\t\t$\u0022{lost} of {Faces.Count} picked faces no longer exist and were skipped.\u0022,\n\t\t\t\t$\u0022{painted} face(s) still painted; {lost} could not be found after an upstream edit.\u0022,\n\t\t\t\t\u0022Pick the missing faces again if they still matter\u0022 );\n\t\t}\n\t}\n}\n"},{"Ident":"pooh.geppetto","Path":"Editor/Effigy/Features/BasicFeatures.cs","FileName":"BasicFeatures.cs","PackageType":"library","CodeKind":"Editor","AssetVersionId":367420,"Code":"using System;\nusing System.Collections.Generic;\nusing System.Linq;\n\nnamespace Effigy;\n\n/// \u003Csummary\u003E\n/// Creates a primitive solid. Onshape\u0027s own Primitives feature works this way \u2014 one dropdown, and\n/// the dialog shows only the fields that shape actually has.\n///\n/// Parameters deliberately changes with Shape rather than showing every field greyed out. That is\n/// the behaviour being copied: a box dialog asks for three lengths and nothing else.\n/// \u003C/summary\u003E\npublic sealed class PrimitiveFeature : Feature\n{\n\tpublic override string TypeName =\u003E Shape.Value;\n\n\tpublic readonly ChoiceParam Shape = new( \u0022Shape\u0022,\n\t\tnew[] { \u0022Box\u0022, \u0022Cylinder\u0022, \u0022Sphere\u0022, \u0022Wedge\u0022, \u0022Tube\u0022, \u0022Plane\u0022 } );\n\n\tpublic readonly FloatParam SizeX = new( \u0022Width\u0022, 1f, 0.0001f, unit: \u0022u\u0022 );\n\tpublic readonly FloatParam SizeY = new( \u0022Depth\u0022, 1f, 0.0001f, unit: \u0022u\u0022 );\n\tpublic readonly FloatParam SizeZ = new( \u0022Height\u0022, 1f, 0.0001f, unit: \u0022u\u0022 );\n\tpublic readonly FloatParam Radius = new( \u0022Radius\u0022, 0.5f, 0.0001f, unit: \u0022u\u0022 );\n\tpublic readonly FloatParam InnerRadius = new( \u0022Inner radius\u0022, 0.3f, 0f, unit: \u0022u\u0022 );\n\tpublic readonly IntParam Segments = new( \u0022Segments\u0022, 16, 3, 512 );\n\tpublic readonly IntParam Divisions = new( \u0022Divisions\u0022, 4, 1, 64 );\n\tpublic readonly Vec3Param Position = new( \u0022Position\u0022, Vec3.Zero );\n\n\t/// \u003Csummary\u003E\n\t/// Per-axis scale, applied about the primitive\u0027s own origin before it is moved into place.\n\t///\n\t/// NOT THE SAME THING AS THE SIZE PARAMETERS, and worth having alongside them. Width/Depth/\n\t/// Height build the shape at a size; this stretches whatever was built, which is the only way\n\t/// to get an ellipsoid out of a sphere or an oval tube out of a round one \u2014 those are defined\n\t/// by a radius and have no per-axis size to set.\n\t/// \u003C/summary\u003E\n\tpublic readonly Vec3Param Scale = new( \u0022Scale\u0022, Vec3.One );\n\n\t/// \u003Csummary\u003E\n\t/// Whether the dialog keeps the three scale axes equal as you edit one of them.\n\t///\n\t/// A UI CONVENIENCE THAT IS PERSISTED, not something the kernel enforces. Scale stays the\n\t/// single truth about the shape \u2014 Execute reads all three axes and never consults this \u2014 so a\n\t/// document always builds exactly what its three numbers say. This only rides along so the\n\t/// dialog can remember that you were editing that primitive uniformly.\n\t/// \u003C/summary\u003E\n\tpublic readonly BoolParam UniformScale = new( \u0022Uniform scale\u0022, false );\n\n\tpublic readonly IntParam Material = new( \u0022Material slot\u0022, 0, 0, 63 ) { Slider = false };\n\n\t/// \u003Csummary\u003EThe slot, folded away with the other features\u0027 \u2014 a primitive is placed and sized,\n\t/// and painted later from the Materials panel.\u003C/summary\u003E\n\tpublic override IReadOnlyList\u003CIParam\u003E AdvancedParameters =\u003E new IParam[] { Material };\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E Shape.Value switch\n\t{\n\t\t\u0022Box\u0022 =\u003E new IParam[] { Shape, SizeX, SizeY, SizeZ, Position, Scale, UniformScale, Material },\n\t\t\u0022Cylinder\u0022 =\u003E new IParam[] { Shape, Radius, SizeZ, Segments, Position, Scale, UniformScale, Material },\n\t\t\u0022Sphere\u0022 =\u003E new IParam[] { Shape, Radius, Divisions, Position, Scale, UniformScale, Material },\n\t\t\u0022Wedge\u0022 =\u003E new IParam[] { Shape, SizeX, SizeY, SizeZ, Position, Scale, UniformScale, Material },\n\t\t\u0022Tube\u0022 =\u003E new IParam[] { Shape, Radius, InnerRadius, SizeZ, Segments, Position, Scale, UniformScale, Material },\n\t\t\u0022Plane\u0022 =\u003E new IParam[] { Shape, SizeX, SizeY, Segments, Position, Scale, UniformScale, Material },\n\t\t_ =\u003E new IParam[] { Shape }\n\t};\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tvar mesh = Shape.Value switch\n\t\t{\n\t\t\t\u0022Box\u0022 =\u003E Primitives.Box( SizeX.Clamped, SizeY.Clamped, SizeZ.Clamped, Material.Clamped ),\n\t\t\t\u0022Cylinder\u0022 =\u003E Primitives.Cylinder( Radius.Clamped, SizeZ.Clamped, Segments.Clamped, Material.Clamped ),\n\t\t\t\u0022Sphere\u0022 =\u003E Primitives.QuadSphere( Radius.Clamped, Divisions.Clamped, Material.Clamped ),\n\t\t\t\u0022Wedge\u0022 =\u003E Primitives.Wedge( SizeX.Clamped, SizeY.Clamped, SizeZ.Clamped, Material.Clamped ),\n\t\t\t\u0022Tube\u0022 =\u003E BuildTube(),\n\t\t\t\u0022Plane\u0022 =\u003E Primitives.Plane( SizeX.Clamped, SizeY.Clamped, Segments.Clamped, Segments.Clamped, Material.Clamped ),\n\t\t\t_ =\u003E throw new FeatureException( new FeatureDiagnostic(\n\t\t\t\tDiagnosticSeverity.Error,\n\t\t\t\t$\u0022unknown shape \u0027{Shape.Value}\u0027\u0022,\n\t\t\t\t\u0022The shape dropdown has a value this feature does not build.\u0022,\n\t\t\t\t\u0022Shape\u0022,\n\t\t\t\tremedies: new[] { \u0022Pick Box, Cylinder, Sphere, Wedge, Tube or Plane\u0022 } ) )\n\t\t};\n\n\t\tvar scale = Scale.Value;\n\n\t\tif ( scale.x == 0f || scale.y == 0f || scale.z == 0f )\n\t\t{\n\t\t\tFailOn( \u0022Scale\u0022,\n\t\t\t\t\u0022Scale cannot be zero on any axis\u0022,\n\t\t\t\t$\u0022Scale is ({scale.x:0.###}, {scale.y:0.###}, {scale.z:0.###}). A zero axis flattens the solid to nothing.\u0022,\n\t\t\t\t\u0022Set every scale axis to a non-zero value\u0022 );\n\t\t}\n\n\t\t// SCALE FIRST, ABOUT THE PRIMITIVE\u0027S OWN ORIGIN. Applied after the translate it would\n\t\t// multiply the position too, so nudging a scaled box would move it by the scale factor and\n\t\t// the number in the Position field would stop meaning where the box is.\n\t\tif ( scale.x != 1f || scale.y != 1f || scale.z != 1f )\n\t\t\tMeshTransform.Apply( mesh, Xform.Scale( scale ) );\n\n\t\tif ( Position.Value.LengthSquared \u003E 0f )\n\t\t\tMeshTransform.Apply( mesh, Xform.Translate( Position.Value ) );\n\n\t\tctx.Bodies.Add( new Body( ctx.NewBodyId(), Name, mesh ) );\n\t}\n\n\tPolyMesh BuildTube()\n\t{\n\t\t// Caught here rather than left to Primitives, so the message names the parameter the user\n\t\t// can actually see in the dialog.\n\t\tif ( InnerRadius.Clamped \u003E= Radius.Clamped )\n\t\t{\n\t\t\tFailOn( \u0022Inner radius\u0022,\n\t\t\t\t\u0022Inner radius must be smaller than radius\u0022,\n\t\t\t\t$\u0022Inner radius is {InnerRadius.Clamped:0.###} and radius is {Radius.Clamped:0.###}.\u0022,\n\t\t\t\t\u0022Reduce Inner radius\u0022,\n\t\t\t\t\u0022Increase Radius\u0022 );\n\t\t}\n\n\t\treturn Primitives.Tube( Radius.Clamped, InnerRadius.Clamped, SizeZ.Clamped, Segments.Clamped, Material.Clamped );\n\t}\n}\n\n/// \u003Csummary\u003EMove, rotate and scale bodies. Onshape\u0027s Transform.\u003C/summary\u003E\npublic sealed class TransformFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Transform\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly Vec3Param Translate = new( \u0022Translate\u0022, Vec3.Zero );\n\tpublic readonly Vec3Param RotationAxis = new( \u0022Rotation axis\u0022, new Vec3( 0, 0, 1 ) );\n\tpublic readonly FloatParam RotationAngle = new( \u0022Angle\u0022, 0f, unit: \u0022deg\u0022 );\n\tpublic readonly Vec3Param Scale = new( \u0022Scale\u0022, Vec3.One );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E\n\t\tnew IParam[] { Bodies, Translate, RotationAxis, RotationAngle, Scale };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tvar scale = Scale.Value;\n\n\t\tif ( scale.x == 0f || scale.y == 0f || scale.z == 0f )\n\t\t{\n\t\t\tFailOn( \u0022Scale\u0022,\n\t\t\t\t\u0022Scale cannot be zero on any axis\u0022,\n\t\t\t\t$\u0022Scale is ({scale.x:0.###}, {scale.y:0.###}, {scale.z:0.###}). A zero axis flattens the solid to nothing.\u0022,\n\t\t\t\t\u0022Set every scale axis to a non-zero value\u0022 );\n\t\t}\n\n\t\t// Scale, then rotate, then translate \u2014 the order a user expects, and the one that keeps a\n\t\t// rotation about the origin from being skewed by a non-uniform scale applied after it.\n\t\tvar xform =\n\t\t\tXform.Translate( Translate.Value )\n\t\t\t* Xform.Rotate( RotationAxis.Value, RotationAngle.Value * MathF.PI / 180f )\n\t\t\t* Xform.Scale( scale );\n\n\t\tforeach ( var body in RequireBodies( ctx, Bodies ) )\n\t\t\tMeshTransform.Apply( body.Mesh, xform );\n\t}\n}\n\n/// \u003Csummary\u003ECopies along a direction. Onshape\u0027s Linear pattern.\u003C/summary\u003E\npublic sealed class LinearPatternFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Linear pattern\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly Vec3Param Direction = new( \u0022Direction\u0022, new Vec3( 1, 0, 0 ) );\n\tpublic readonly FloatParam Spacing = new( \u0022Spacing\u0022, 1f, unit: \u0022u\u0022 );\n\tpublic readonly IntParam Count = new( \u0022Instances\u0022, 3, 1, 4096 );\n\tpublic readonly BoolParam Merge = new( \u0022Merge into one body\u0022, false );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E\n\t\tnew IParam[] { Bodies, Direction, Spacing, Count, Merge };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Direction.Value.LengthSquared \u003C 1e-12f )\n\t\t{\n\t\t\tFailOn( \u0022Direction\u0022,\n\t\t\t\t\u0022Direction cannot be zero\u0022,\n\t\t\t\t\u0022A linear pattern copies along a direction, and this one has no length, so there is nowhere to put the copies.\u0022,\n\t\t\t\t\u0022Set Direction to the axis you want the copies to run along\u0022 );\n\t\t}\n\n\t\tvar dir = Direction.Value.Normal;\n\t\tvar sources = RequireBodies( ctx, Bodies );\n\n\t\t// Instance 0 is the original, so a count of 3 means the original plus two copies \u2014 which\n\t\t// is what Onshape\u0027s instance count means too.\n\t\tforeach ( var source in sources )\n\t\t{\n\t\t\t// Snapshot BEFORE the loop. With Merge on, the loop appends into source.Mesh, so\n\t\t\t// reading source.Mesh each iteration would copy the copies too and the instance count\n\t\t\t// would double rather than increment: 6, 12, 24, 48 faces instead of 6, 12, 18, 24.\n\t\t\tvar original = source.Mesh.Clone();\n\n\t\t\tfor ( var i = 1; i \u003C Count.Clamped; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar copy = MeshTransform.Transformed( original, Xform.Translate( dir * (Spacing.Value * i) ) );\n\n\t\t\t\tif ( Merge.Value )\n\t\t\t\t\tMeshTransform.Append( source.Mesh, copy );\n\t\t\t\telse\n\t\t\t\t\tctx.Bodies.Add( new Body( ctx.NewBodyId(), $\u0022{Name} {i}\u0022, copy ) );\n\t\t\t}\n\t\t}\n\t}\n}\n\n/// \u003Csummary\u003ECopies around an axis. Onshape\u0027s Circular pattern.\u003C/summary\u003E\npublic sealed class CircularPatternFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Circular pattern\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly Vec3Param AxisPoint = new( \u0022Axis through\u0022, Vec3.Zero );\n\tpublic readonly Vec3Param AxisDirection = new( \u0022Axis\u0022, new Vec3( 0, 0, 1 ) );\n\tpublic readonly IntParam Count = new( \u0022Instances\u0022, 4, 1, 4096 );\n\tpublic readonly FloatParam TotalAngle = new( \u0022Angle\u0022, 360f, unit: \u0022deg\u0022 );\n\tpublic readonly BoolParam Merge = new( \u0022Merge into one body\u0022, false );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E\n\t\tnew IParam[] { Bodies, AxisPoint, AxisDirection, Count, TotalAngle, Merge };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( AxisDirection.Value.LengthSquared \u003C 1e-12f )\n\t\t{\n\t\t\tFailOn( \u0022Axis\u0022,\n\t\t\t\t\u0022Axis cannot be zero\u0022,\n\t\t\t\t\u0022A circular pattern spins around an axis, and this one has no length.\u0022,\n\t\t\t\t\u0022Set Axis to the direction to spin around\u0022 );\n\t\t}\n\n\t\tvar sources = RequireBodies( ctx, Bodies );\n\n\t\tvar count = Count.Clamped;\n\n\t\t// A full turn puts instance N back on instance 0, so the step divides by count. A partial\n\t\t// sweep spreads the instances across the arc inclusive of both ends instead.\n\t\tvar full = MathF.Abs( MathF.Abs( TotalAngle.Value ) - 360f ) \u003C 1e-3f;\n\t\tvar step = count \u003C= 1 ? 0f : TotalAngle.Value / (full ? count : count - 1);\n\n\t\tforeach ( var source in sources )\n\t\t{\n\t\t\t// Snapshot before the loop \u2014 same compounding trap as the linear pattern.\n\t\t\tvar original = source.Mesh.Clone();\n\n\t\t\tfor ( var i = 1; i \u003C count; i\u002B\u002B )\n\t\t\t{\n\t\t\t\tvar xform = Xform.RotateAbout(\n\t\t\t\t\tAxisPoint.Value, AxisDirection.Value, step * i * MathF.PI / 180f );\n\n\t\t\t\tvar copy = MeshTransform.Transformed( original, xform );\n\n\t\t\t\tif ( Merge.Value )\n\t\t\t\t\tMeshTransform.Append( source.Mesh, copy );\n\t\t\t\telse\n\t\t\t\t\tctx.Bodies.Add( new Body( ctx.NewBodyId(), $\u0022{Name} {i}\u0022, copy ) );\n\t\t\t}\n\t\t}\n\t}\n}\n\n/// \u003Csummary\u003EReflects bodies in a plane. Onshape\u0027s Mirror.\u003C/summary\u003E\npublic sealed class MirrorFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Mirror\u0022;\n\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Body;\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly Vec3Param PlanePoint = new( \u0022Plane through\u0022, Vec3.Zero );\n\tpublic readonly Vec3Param PlaneNormal = new( \u0022Plane normal\u0022, new Vec3( 1, 0, 0 ) );\n\tpublic readonly BoolParam KeepOriginal = new( \u0022Keep original\u0022, true );\n\tpublic readonly BoolParam Merge = new( \u0022Merge into one body\u0022, false );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E\n\t\tnew IParam[] { Bodies, PlanePoint, PlaneNormal, KeepOriginal, Merge };\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( PlaneNormal.Value.LengthSquared \u003C 1e-12f )\n\t\t{\n\t\t\tFailOn( \u0022Plane normal\u0022,\n\t\t\t\t\u0022Plane normal cannot be zero\u0022,\n\t\t\t\t\u0022A mirror needs a plane to reflect across, and a zero normal does not define one.\u0022,\n\t\t\t\t\u0022Set Plane normal to the direction the mirror should face\u0022 );\n\t\t}\n\n\t\tvar xform = Xform.Mirror( PlanePoint.Value, PlaneNormal.Value );\n\t\tvar sources = RequireBodies( ctx, Bodies );\n\n\t\tforeach ( var source in sources )\n\t\t{\n\t\t\t// MeshTransform.Apply reverses winding for us \u2014 the mirror flips handedness, and\n\t\t\t// without that reversal every mirrored face would point into the solid.\n\t\t\tvar copy = MeshTransform.Transformed( source.Mesh, xform );\n\n\t\t\tif ( Merge.Value )\n\t\t\t\tMeshTransform.Append( source.Mesh, copy );\n\t\t\telse\n\t\t\t\tctx.Bodies.Add( new Body( ctx.NewBodyId(), $\u0022{Name}\u0022, copy ) );\n\t\t}\n\n\t\tif ( !KeepOriginal.Value )\n\t\t{\n\t\t\tforeach ( var source in sources )\n\t\t\t\tctx.Bodies.Remove( source );\n\t\t}\n\t}\n}\n\n/// \u003Csummary\u003E\n/// Catmull-Clark subdivision as a history feature.\n///\n/// Onshape has no equivalent, and this is the deliberate place where the tool stops being CAD.\n/// Putting subdivision IN the tree rather than after it is what keeps the pipeline honest: roll\n/// the bar back above this feature and you are editing the low-poly cage, roll it forward and you\n/// see the dense surface. The cage is what the sculpt eventually bakes down onto, so it has to\n/// stay reachable rather than being consumed by an export step.\n/// \u003C/summary\u003E\npublic sealed class SubdivideFeature : Feature\n{\n\tpublic override string TypeName =\u003E \u0022Subdivide\u0022;\n\n\t/// \u003Csummary\u003EFaces as well as bodies: an empty Bodies list means the whole document to Execute,\n\t/// so picking is how you say which part - see the refusal in the editor\u0027s AddFeature.\u003C/summary\u003E\n\tpublic override GeometryKind Accepts =\u003E GeometryKind.Face | GeometryKind.Body;\n\n\t/// \u003Csummary\u003E\n\t/// Which faces to subdivide. EMPTY MEANS THE WHOLE BODY, which is both the old behaviour and\n\t/// the honest default \u2014 a subdivision surface is a property of a cage, not of a corner of one.\n\t///\n\t/// Picking faces switches the operation from smooth to linear, and the two really are different\n\t/// operations rather than a flag on one. See CatmullClark.SubdivideFaces: you cannot apply the\n\t/// limit rules to part of a mesh without moving the vertices the rest of it is standing on. So\n\t/// this is density where you need it \u2014 a face about to be sculpted, a panel about to be bent \u2014\n\t/// and the whole-body form remains the one that smooths.\n\t///\n\t/// Held as FaceRefs, like every other face pick, so the choice survives the rebuild that\n\t/// recreates the faces it names.\n\t/// \u003C/summary\u003E\n\tpublic List\u003CFaceRef\u003E Faces = new();\n\n\tpublic readonly BodySelectionParam Bodies = new( \u0022Bodies\u0022 );\n\tpublic readonly IntParam Levels = new( \u0022Levels\u0022, 1, 0, 6 );\n\n\tpublic override IReadOnlyList\u003CIParam\u003E Parameters =\u003E new IParam[] { Bodies, Levels };\n\n\t/// \u003Csummary\u003EWhat this feature will cost at the current settings, for a UI that warns before\n\t/// rather than after. Levels are exponential and the jump from 4 to 6 is 16x.\u003C/summary\u003E\n\tpublic (int Vertices, int Faces) PredictCost( IEnumerable\u003CBody\u003E bodies )\n\t{\n\t\tvar v = 0;\n\t\tvar f = 0;\n\t\tvar list = bodies as IList\u003CBody\u003E ?? bodies.ToList();\n\n\t\tif ( Faces.Count \u003E 0 )\n\t\t{\n\t\t\t// Local subdivision only touches the bodies that were picked on, so the bodies it did\n\t\t\t// not touch still cost exactly what they already are.\n\t\t\tvar picked = Resolve( list, out _ );\n\n\t\t\tforeach ( var body in list )\n\t\t\t{\n\t\t\t\tvar (bv, bf) = picked.TryGetValue( body, out var indices )\n\t\t\t\t\t? CatmullClark.PredictLocalCost( body.Mesh, indices, Levels.Clamped )\n\t\t\t\t\t: (body.Mesh.VertexCount, body.Mesh.FaceCount);\n\n\t\t\t\tv \u002B= bv;\n\t\t\t\tf \u002B= bf;\n\t\t\t}\n\n\t\t\treturn (v, f);\n\t\t}\n\n\t\tforeach ( var body in list.Where( Bodies.Matches ) )\n\t\t{\n\t\t\tvar (bv, bf) = CatmullClark.PredictCost( body.Mesh, Levels.Clamped );\n\t\t\tv \u002B= bv;\n\t\t\tf \u002B= bf;\n\t\t}\n\n\t\treturn (v, f);\n\t}\n\n\t/// \u003Csummary\u003EPicked faces grouped by the body they landed on. \u003Cparamref name=\u0022lost\u0022/\u003E counts the\n\t/// references that no longer resolve \u2014 geometry upstream changed under them.\u003C/summary\u003E\n\tDictionary\u003CBody, List\u003Cint\u003E\u003E Resolve( IEnumerable\u003CBody\u003E bodies, out int lost )\n\t{\n\t\tvar byBody = new Dictionary\u003CBody, List\u003Cint\u003E\u003E();\n\t\tlost = 0;\n\n\t\tforeach ( var reference in Faces )\n\t\t{\n\t\t\tif ( !FacePlane.TryResolveFace( bodies, reference, out var body, out var faceIndex ) )\n\t\t\t{\n\t\t\t\tlost\u002B\u002B;\n\t\t\t\tcontinue;\n\t\t\t}\n\n\t\t\tif ( !byBody.TryGetValue( body, out var indices ) )\n\t\t\t\tbyBody[body] = indices = new List\u003Cint\u003E();\n\n\t\t\tindices.Add( faceIndex );\n\t\t}\n\n\t\treturn byBody;\n\t}\n\n\tprotected override void Execute( FeatureContext ctx )\n\t{\n\t\tif ( Levels.Clamped == 0 )\n\t\t\treturn;\n\n\t\tif ( Faces.Count == 0 )\n\t\t{\n\t\t\tforeach ( var body in RequireBodies( ctx, Bodies ) )\n\t\t\t\tbody.Mesh = CatmullClark.Subdivide( body.Mesh, Levels.Clamped );\n\n\t\t\treturn;\n\t\t}\n\n\t\t// A picked face already names its body, so the Bodies filter has nothing left to decide and\n\t\t// is not consulted here. Two picks on the same body are subdivided in ONE call rather than\n\t\t// one call each: the second call would be running against a mesh whose face indices the\n\t\t// first has already renumbered.\n\t\tvar picked = Resolve( ctx.Bodies, out var lost );\n\n\t\tif ( picked.Count == 0 )\n\t\t{\n\t\t\tFail(\n\t\t\t\t\u0022None of the picked faces are still there\u0022,\n\t\t\t\t\u0022Every face this feature subdivides was removed or replaced by a change further up the tree.\u0022,\n\t\t\t\t\u0022Pick the faces again\u0022,\n\t\t\t\t\u0022Or clear the picks to subdivide the whole body\u0022 );\n\t\t}\n\n\t\tforeach ( var (body, indices) in picked )\n\t\t\tbody.Mesh = CatmullClark.SubdivideFaces( body.Mesh, indices, Levels.Clamped );\n\n\t\tif ( lost \u003E 0 )\n\t\t{\n\t\t\tWarn(\n\t\t\t\t$\u0022{lost} picked {(lost == 1 ? \u0022face is\u0022 : \u0022faces are\u0022)} no longer there\u0022,\n\t\t\t\t\u0022A change further up the tree removed or replaced them, so they were skipped.\u0022,\n\t\t\t\t\u0022Pick them again if the extra density is still wanted\u0022 );\n\t\t}\n\t}\n}\n"}]}