Editor/Effigy/Features/BasicFeatures.cs
using System;
using System.Collections.Generic;
using System.Linq;
namespace Effigy;
/// <summary>
/// Creates a primitive solid. Onshape's own Primitives feature works this way — one dropdown, and
/// the dialog shows only the fields that shape actually has.
///
/// Parameters deliberately changes with Shape rather than showing every field greyed out. That is
/// the behaviour being copied: a box dialog asks for three lengths and nothing else.
/// </summary>
public sealed class PrimitiveFeature : Feature
{
public override string TypeName => Shape.Value;
public readonly ChoiceParam Shape = new( "Shape",
new[] { "Box", "Cylinder", "Sphere", "Wedge", "Tube", "Plane" } );
public readonly FloatParam SizeX = new( "Width", 1f, 0.0001f, unit: "u" );
public readonly FloatParam SizeY = new( "Depth", 1f, 0.0001f, unit: "u" );
public readonly FloatParam SizeZ = new( "Height", 1f, 0.0001f, unit: "u" );
public readonly FloatParam Radius = new( "Radius", 0.5f, 0.0001f, unit: "u" );
public readonly FloatParam InnerRadius = new( "Inner radius", 0.3f, 0f, unit: "u" );
public readonly IntParam Segments = new( "Segments", 16, 3, 512 );
public readonly IntParam Divisions = new( "Divisions", 4, 1, 64 );
public readonly Vec3Param Position = new( "Position", Vec3.Zero );
/// <summary>
/// Per-axis scale, applied about the primitive's own origin before it is moved into place.
///
/// NOT THE SAME THING AS THE SIZE PARAMETERS, and worth having alongside them. Width/Depth/
/// Height build the shape at a size; this stretches whatever was built, which is the only way
/// to get an ellipsoid out of a sphere or an oval tube out of a round one — those are defined
/// by a radius and have no per-axis size to set.
/// </summary>
public readonly Vec3Param Scale = new( "Scale", Vec3.One );
/// <summary>
/// Whether the dialog keeps the three scale axes equal as you edit one of them.
///
/// A UI CONVENIENCE THAT IS PERSISTED, not something the kernel enforces. Scale stays the
/// single truth about the shape — Execute reads all three axes and never consults this — so a
/// document always builds exactly what its three numbers say. This only rides along so the
/// dialog can remember that you were editing that primitive uniformly.
/// </summary>
public readonly BoolParam UniformScale = new( "Uniform scale", false );
public readonly IntParam Material = new( "Material slot", 0, 0, 63 ) { Slider = false };
/// <summary>The slot, folded away with the other features' — a primitive is placed and sized,
/// and painted later from the Materials panel.</summary>
public override IReadOnlyList<IParam> AdvancedParameters => new IParam[] { Material };
public override IReadOnlyList<IParam> Parameters => Shape.Value switch
{
"Box" => new IParam[] { Shape, SizeX, SizeY, SizeZ, Position, Scale, UniformScale, Material },
"Cylinder" => new IParam[] { Shape, Radius, SizeZ, Segments, Position, Scale, UniformScale, Material },
"Sphere" => new IParam[] { Shape, Radius, Divisions, Position, Scale, UniformScale, Material },
"Wedge" => new IParam[] { Shape, SizeX, SizeY, SizeZ, Position, Scale, UniformScale, Material },
"Tube" => new IParam[] { Shape, Radius, InnerRadius, SizeZ, Segments, Position, Scale, UniformScale, Material },
"Plane" => new IParam[] { Shape, SizeX, SizeY, Segments, Position, Scale, UniformScale, Material },
_ => new IParam[] { Shape }
};
protected override void Execute( FeatureContext ctx )
{
var mesh = Shape.Value switch
{
"Box" => Primitives.Box( SizeX.Clamped, SizeY.Clamped, SizeZ.Clamped, Material.Clamped ),
"Cylinder" => Primitives.Cylinder( Radius.Clamped, SizeZ.Clamped, Segments.Clamped, Material.Clamped ),
"Sphere" => Primitives.QuadSphere( Radius.Clamped, Divisions.Clamped, Material.Clamped ),
"Wedge" => Primitives.Wedge( SizeX.Clamped, SizeY.Clamped, SizeZ.Clamped, Material.Clamped ),
"Tube" => BuildTube(),
"Plane" => Primitives.Plane( SizeX.Clamped, SizeY.Clamped, Segments.Clamped, Segments.Clamped, Material.Clamped ),
_ => throw new FeatureException( new FeatureDiagnostic(
DiagnosticSeverity.Error,
$"unknown shape '{Shape.Value}'",
"The shape dropdown has a value this feature does not build.",
"Shape",
remedies: new[] { "Pick Box, Cylinder, Sphere, Wedge, Tube or Plane" } ) )
};
var scale = Scale.Value;
if ( scale.x == 0f || scale.y == 0f || scale.z == 0f )
{
FailOn( "Scale",
"Scale cannot be zero on any axis",
$"Scale is ({scale.x:0.###}, {scale.y:0.###}, {scale.z:0.###}). A zero axis flattens the solid to nothing.",
"Set every scale axis to a non-zero value" );
}
// SCALE FIRST, ABOUT THE PRIMITIVE'S OWN ORIGIN. Applied after the translate it would
// multiply the position too, so nudging a scaled box would move it by the scale factor and
// the number in the Position field would stop meaning where the box is.
if ( scale.x != 1f || scale.y != 1f || scale.z != 1f )
MeshTransform.Apply( mesh, Xform.Scale( scale ) );
if ( Position.Value.LengthSquared > 0f )
MeshTransform.Apply( mesh, Xform.Translate( Position.Value ) );
ctx.Bodies.Add( new Body( ctx.NewBodyId(), Name, mesh ) );
}
PolyMesh BuildTube()
{
// Caught here rather than left to Primitives, so the message names the parameter the user
// can actually see in the dialog.
if ( InnerRadius.Clamped >= Radius.Clamped )
{
FailOn( "Inner radius",
"Inner radius must be smaller than radius",
$"Inner radius is {InnerRadius.Clamped:0.###} and radius is {Radius.Clamped:0.###}.",
"Reduce Inner radius",
"Increase Radius" );
}
return Primitives.Tube( Radius.Clamped, InnerRadius.Clamped, SizeZ.Clamped, Segments.Clamped, Material.Clamped );
}
}
/// <summary>Move, rotate and scale bodies. Onshape's Transform.</summary>
public sealed class TransformFeature : Feature
{
public override string TypeName => "Transform";
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly Vec3Param Translate = new( "Translate", Vec3.Zero );
public readonly Vec3Param RotationAxis = new( "Rotation axis", new Vec3( 0, 0, 1 ) );
public readonly FloatParam RotationAngle = new( "Angle", 0f, unit: "deg" );
public readonly Vec3Param Scale = new( "Scale", Vec3.One );
public override IReadOnlyList<IParam> Parameters =>
new IParam[] { Bodies, Translate, RotationAxis, RotationAngle, Scale };
protected override void Execute( FeatureContext ctx )
{
var scale = Scale.Value;
if ( scale.x == 0f || scale.y == 0f || scale.z == 0f )
{
FailOn( "Scale",
"Scale cannot be zero on any axis",
$"Scale is ({scale.x:0.###}, {scale.y:0.###}, {scale.z:0.###}). A zero axis flattens the solid to nothing.",
"Set every scale axis to a non-zero value" );
}
// Scale, then rotate, then translate — the order a user expects, and the one that keeps a
// rotation about the origin from being skewed by a non-uniform scale applied after it.
var xform =
Xform.Translate( Translate.Value )
* Xform.Rotate( RotationAxis.Value, RotationAngle.Value * MathF.PI / 180f )
* Xform.Scale( scale );
foreach ( var body in RequireBodies( ctx, Bodies ) )
MeshTransform.Apply( body.Mesh, xform );
}
}
/// <summary>Copies along a direction. Onshape's Linear pattern.</summary>
public sealed class LinearPatternFeature : Feature
{
public override string TypeName => "Linear pattern";
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly Vec3Param Direction = new( "Direction", new Vec3( 1, 0, 0 ) );
public readonly FloatParam Spacing = new( "Spacing", 1f, unit: "u" );
public readonly IntParam Count = new( "Instances", 3, 1, 4096 );
public readonly BoolParam Merge = new( "Merge into one body", false );
public override IReadOnlyList<IParam> Parameters =>
new IParam[] { Bodies, Direction, Spacing, Count, Merge };
protected override void Execute( FeatureContext ctx )
{
if ( Direction.Value.LengthSquared < 1e-12f )
{
FailOn( "Direction",
"Direction cannot be zero",
"A linear pattern copies along a direction, and this one has no length, so there is nowhere to put the copies.",
"Set Direction to the axis you want the copies to run along" );
}
var dir = Direction.Value.Normal;
var sources = RequireBodies( ctx, Bodies );
// Instance 0 is the original, so a count of 3 means the original plus two copies — which
// is what Onshape's instance count means too.
foreach ( var source in sources )
{
// Snapshot BEFORE the loop. With Merge on, the loop appends into source.Mesh, so
// reading source.Mesh each iteration would copy the copies too and the instance count
// would double rather than increment: 6, 12, 24, 48 faces instead of 6, 12, 18, 24.
var original = source.Mesh.Clone();
for ( var i = 1; i < Count.Clamped; i++ )
{
var copy = MeshTransform.Transformed( original, Xform.Translate( dir * (Spacing.Value * i) ) );
if ( Merge.Value )
MeshTransform.Append( source.Mesh, copy );
else
ctx.Bodies.Add( new Body( ctx.NewBodyId(), $"{Name} {i}", copy ) );
}
}
}
}
/// <summary>Copies around an axis. Onshape's Circular pattern.</summary>
public sealed class CircularPatternFeature : Feature
{
public override string TypeName => "Circular pattern";
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly Vec3Param AxisPoint = new( "Axis through", Vec3.Zero );
public readonly Vec3Param AxisDirection = new( "Axis", new Vec3( 0, 0, 1 ) );
public readonly IntParam Count = new( "Instances", 4, 1, 4096 );
public readonly FloatParam TotalAngle = new( "Angle", 360f, unit: "deg" );
public readonly BoolParam Merge = new( "Merge into one body", false );
public override IReadOnlyList<IParam> Parameters =>
new IParam[] { Bodies, AxisPoint, AxisDirection, Count, TotalAngle, Merge };
protected override void Execute( FeatureContext ctx )
{
if ( AxisDirection.Value.LengthSquared < 1e-12f )
{
FailOn( "Axis",
"Axis cannot be zero",
"A circular pattern spins around an axis, and this one has no length.",
"Set Axis to the direction to spin around" );
}
var sources = RequireBodies( ctx, Bodies );
var count = Count.Clamped;
// A full turn puts instance N back on instance 0, so the step divides by count. A partial
// sweep spreads the instances across the arc inclusive of both ends instead.
var full = MathF.Abs( MathF.Abs( TotalAngle.Value ) - 360f ) < 1e-3f;
var step = count <= 1 ? 0f : TotalAngle.Value / (full ? count : count - 1);
foreach ( var source in sources )
{
// Snapshot before the loop — same compounding trap as the linear pattern.
var original = source.Mesh.Clone();
for ( var i = 1; i < count; i++ )
{
var xform = Xform.RotateAbout(
AxisPoint.Value, AxisDirection.Value, step * i * MathF.PI / 180f );
var copy = MeshTransform.Transformed( original, xform );
if ( Merge.Value )
MeshTransform.Append( source.Mesh, copy );
else
ctx.Bodies.Add( new Body( ctx.NewBodyId(), $"{Name} {i}", copy ) );
}
}
}
}
/// <summary>Reflects bodies in a plane. Onshape's Mirror.</summary>
public sealed class MirrorFeature : Feature
{
public override string TypeName => "Mirror";
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly Vec3Param PlanePoint = new( "Plane through", Vec3.Zero );
public readonly Vec3Param PlaneNormal = new( "Plane normal", new Vec3( 1, 0, 0 ) );
public readonly BoolParam KeepOriginal = new( "Keep original", true );
public readonly BoolParam Merge = new( "Merge into one body", false );
public override IReadOnlyList<IParam> Parameters =>
new IParam[] { Bodies, PlanePoint, PlaneNormal, KeepOriginal, Merge };
protected override void Execute( FeatureContext ctx )
{
if ( PlaneNormal.Value.LengthSquared < 1e-12f )
{
FailOn( "Plane normal",
"Plane normal cannot be zero",
"A mirror needs a plane to reflect across, and a zero normal does not define one.",
"Set Plane normal to the direction the mirror should face" );
}
var xform = Xform.Mirror( PlanePoint.Value, PlaneNormal.Value );
var sources = RequireBodies( ctx, Bodies );
foreach ( var source in sources )
{
// MeshTransform.Apply reverses winding for us — the mirror flips handedness, and
// without that reversal every mirrored face would point into the solid.
var copy = MeshTransform.Transformed( source.Mesh, xform );
if ( Merge.Value )
MeshTransform.Append( source.Mesh, copy );
else
ctx.Bodies.Add( new Body( ctx.NewBodyId(), $"{Name}", copy ) );
}
if ( !KeepOriginal.Value )
{
foreach ( var source in sources )
ctx.Bodies.Remove( source );
}
}
}
/// <summary>
/// Catmull-Clark subdivision as a history feature.
///
/// Onshape has no equivalent, and this is the deliberate place where the tool stops being CAD.
/// Putting subdivision IN the tree rather than after it is what keeps the pipeline honest: roll
/// the bar back above this feature and you are editing the low-poly cage, roll it forward and you
/// see the dense surface. The cage is what the sculpt eventually bakes down onto, so it has to
/// stay reachable rather than being consumed by an export step.
/// </summary>
public sealed class SubdivideFeature : Feature
{
public override string TypeName => "Subdivide";
/// <summary>
/// Which faces to subdivide. EMPTY MEANS THE WHOLE BODY, which is both the old behaviour and
/// the honest default — a subdivision surface is a property of a cage, not of a corner of one.
///
/// Picking faces switches the operation from smooth to linear, and the two really are different
/// operations rather than a flag on one. See CatmullClark.SubdivideFaces: you cannot apply the
/// limit rules to part of a mesh without moving the vertices the rest of it is standing on. So
/// this is density where you need it — a face about to be sculpted, a panel about to be bent —
/// and the whole-body form remains the one that smooths.
///
/// Held as FaceRefs, like every other face pick, so the choice survives the rebuild that
/// recreates the faces it names.
/// </summary>
public List<FaceRef> Faces = new();
public readonly BodySelectionParam Bodies = new( "Bodies" );
public readonly IntParam Levels = new( "Levels", 1, 0, 6 );
public override IReadOnlyList<IParam> Parameters => new IParam[] { Bodies, Levels };
/// <summary>What this feature will cost at the current settings, for a UI that warns before
/// rather than after. Levels are exponential and the jump from 4 to 6 is 16x.</summary>
public (int Vertices, int Faces) PredictCost( IEnumerable<Body> bodies )
{
var v = 0;
var f = 0;
var list = bodies as IList<Body> ?? bodies.ToList();
if ( Faces.Count > 0 )
{
// Local subdivision only touches the bodies that were picked on, so the bodies it did
// not touch still cost exactly what they already are.
var picked = Resolve( list, out _ );
foreach ( var body in list )
{
var (bv, bf) = picked.TryGetValue( body, out var indices )
? CatmullClark.PredictLocalCost( body.Mesh, indices, Levels.Clamped )
: (body.Mesh.VertexCount, body.Mesh.FaceCount);
v += bv;
f += bf;
}
return (v, f);
}
foreach ( var body in list.Where( Bodies.Matches ) )
{
var (bv, bf) = CatmullClark.PredictCost( body.Mesh, Levels.Clamped );
v += bv;
f += bf;
}
return (v, f);
}
/// <summary>Picked faces grouped by the body they landed on. <paramref name="lost"/> counts the
/// references that no longer resolve — geometry upstream changed under them.</summary>
Dictionary<Body, List<int>> Resolve( IEnumerable<Body> bodies, out int lost )
{
var byBody = new Dictionary<Body, List<int>>();
lost = 0;
foreach ( var reference in Faces )
{
if ( !FacePlane.TryResolveFace( bodies, reference, out var body, out var faceIndex ) )
{
lost++;
continue;
}
if ( !byBody.TryGetValue( body, out var indices ) )
byBody[body] = indices = new List<int>();
indices.Add( faceIndex );
}
return byBody;
}
protected override void Execute( FeatureContext ctx )
{
if ( Levels.Clamped == 0 )
return;
if ( Faces.Count == 0 )
{
foreach ( var body in RequireBodies( ctx, Bodies ) )
body.Mesh = CatmullClark.Subdivide( body.Mesh, Levels.Clamped );
return;
}
// A picked face already names its body, so the Bodies filter has nothing left to decide and
// is not consulted here. Two picks on the same body are subdivided in ONE call rather than
// one call each: the second call would be running against a mesh whose face indices the
// first has already renumbered.
var picked = Resolve( ctx.Bodies, out var lost );
if ( picked.Count == 0 )
{
Fail(
"None of the picked faces are still there",
"Every face this feature subdivides was removed or replaced by a change further up the tree.",
"Pick the faces again",
"Or clear the picks to subdivide the whole body" );
}
foreach ( var (body, indices) in picked )
body.Mesh = CatmullClark.SubdivideFaces( body.Mesh, indices, Levels.Clamped );
if ( lost > 0 )
{
Warn(
$"{lost} picked {(lost == 1 ? "face is" : "faces are")} no longer there",
"A change further up the tree removed or replaced them, so they were skipped.",
"Pick them again if the extra density is still wanted" );
}
}
}