Editor/Designers/ArchPrefabAiming.cs
using System;
using System.Collections.Generic;
using System.Linq;
using Editor;
using Sandbox;
namespace Sunless.Architecture;
// Every mount is read in the same cardinal frame, whichever of its axes means what: a cabinet's +x is out of the
// face, a door leaf's is across the opening, and squaring a picked edge only ever asks which axis it lies nearest.
public static class ArchAimedPrefabMount {
public static readonly Vector3[] Axes = { Vector3.Forward, Vector3.Left, Vector3.Up };
public static BBox Of( float depth, float width, float height ) {
return new BBox( new Vector3( 0f, 0f, -MathF.Max( 1f, height ) ),
new Vector3( MathF.Max( 0.25f, depth ), MathF.Max( 1f, width ), 0f ) );
}
}
// The room a prefab is designed against, so the author judges their art at the size it will actually land rather
// than floating on its own. A front gets the standard base-unit opening; a whole carcass gets the whole module,
// because aiming a cabinet inside a door-sized box would put every anchor in the wrong place.
public static class ArchCabinetFrontMount {
public const float Depth = 1.25f;
public const float Width = 23.5f;
public const float Height = 26f;
// The mount's own frame, laid out at the world origin: +x out of the face, +y along the run, -z down from the
// top. Identical to ArchCabinetShape's, which is what lets the designer preview the real landing.
public static BBox Room => ArchAimedPrefabMount.Of( Depth, Width, Height );
}
// One prefab's triangles, edges and corners in the prefab's OWN space, ready to be aimed at. Built off the model
// buffers rather than physics, because a front an author drew may carry no collider at all.
public sealed class ArchAimedPrefabGeometry {
public List<Vector3> A { get; } = new();
public List<Vector3> B { get; } = new();
public List<Vector3> C { get; } = new();
public BBox Bounds { get; private set; }
public bool Standing => A.Count > 0;
public static ArchAimedPrefabGeometry Of( GameObject root ) {
var built = new ArchAimedPrefabGeometry();
if ( !root.IsValid() ) {
return built;
}
var origin = root.WorldTransform;
var found = false;
var bounds = default( BBox );
foreach ( var renderer in root.Components.GetAll<ModelRenderer>( FindMode.EnabledInSelfAndDescendants ) ) {
if ( renderer.Model is not { } model ) {
continue;
}
built.Take( model, origin.ToLocal( renderer.WorldTransform ), ref bounds, ref found );
}
built.Bounds = found ? bounds : BBox.FromPositionAndSize( Vector3.Zero, 16f );
return built;
}
// Indices are per-mesh local when they stay under that mesh's vertex count, else already global - the same
// auto-detect the hide-mask painter needs, and the same reason: nothing in the buffers says which it is.
void Take( Model model, Transform placed, ref BBox bounds, ref bool found ) {
var vertices = model.GetVertices();
var indices = model.GetIndices();
if ( vertices is null || indices is null ) {
return;
}
var vertexOffset = 0;
var triangleOffset = 0;
foreach ( var info in model.MeshInfo.Meshes ) {
var start = triangleOffset * 3;
var end = Math.Min( indices.Length, start + info.Triangles * 3 );
uint highest = 0;
for ( var index = start; index < end; index++ ) {
highest = Math.Max( highest, indices[index] );
}
var origin = highest < (uint)info.Vertices ? vertexOffset : 0;
for ( var index = start; index + 2 < end; index += 3 ) {
var one = placed.PointToWorld( vertices[origin + indices[index]].Position );
var two = placed.PointToWorld( vertices[origin + indices[index + 1]].Position );
var three = placed.PointToWorld( vertices[origin + indices[index + 2]].Position );
A.Add( one );
B.Add( two );
C.Add( three );
foreach ( var point in new[] { one, two, three } ) {
bounds = found ? bounds.AddPoint( point ) : BBox.FromPositionAndSize( point, 0f );
found = true;
}
}
vertexOffset += info.Vertices;
triangleOffset += info.Triangles;
}
}
// The aim SNAPS: whichever corner or edge of the triangle under the cursor is nearest the hit wins, so the
// author points roughly and lands exactly on the feature they meant.
public bool Aim( Vector3 origin, Vector3 direction, out Vector3 anchor, out Vector3 along ) {
anchor = Vector3.Zero;
along = Vector3.Zero;
var nearest = float.MaxValue;
var found = -1;
var landed = Vector3.Zero;
for ( var triangle = 0; triangle < A.Count; triangle++ ) {
if ( !Hits( origin, direction, A[triangle], B[triangle], C[triangle], out var distance ) || distance >= nearest ) {
continue;
}
nearest = distance;
found = triangle;
landed = origin + direction * distance;
}
if ( found < 0 ) {
return false;
}
Snap( landed, A[found], B[found], C[found], out anchor, out along );
return true;
}
// A corner wins over the edge it sits on whenever the aim is inside the corner's own share of it, which is what
// stops a vertex being unreachable on a long edge.
static void Snap( Vector3 landed, Vector3 one, Vector3 two, Vector3 three, out Vector3 anchor, out Vector3 along ) {
var corners = new[] { one, two, three };
var closest = corners.OrderBy( corner => corner.Distance( landed ) ).First();
var edges = new[] { (one, two), (two, three), (three, one) };
var pick = edges.OrderBy( edge => OnEdge( landed, edge.Item1, edge.Item2 ).Distance( landed ) ).First();
var span = (pick.Item2 - pick.Item1).Length;
if ( closest.Distance( landed ) <= span * 0.25f ) {
anchor = closest;
along = Vector3.Zero;
return;
}
anchor = (pick.Item1 + pick.Item2) * 0.5f;
along = span < 0.001f ? Vector3.Zero : (pick.Item2 - pick.Item1) / span;
}
static Vector3 OnEdge( Vector3 point, Vector3 from, Vector3 to ) {
var run = to - from;
var length = run.LengthSquared;
if ( length < 0.000001f ) {
return from;
}
return from + run * Math.Clamp( Vector3.Dot( point - from, run ) / length, 0f, 1f );
}
static bool Hits( Vector3 origin, Vector3 direction, Vector3 one, Vector3 two, Vector3 three, out float distance ) {
distance = 0f;
var first = two - one;
var second = three - one;
var cross = Vector3.Cross( direction, second );
var determinant = Vector3.Dot( first, cross );
if ( determinant > -1e-6f && determinant < 1e-6f ) {
return false;
}
var inverse = 1f / determinant;
var toOrigin = origin - one;
var u = Vector3.Dot( toOrigin, cross ) * inverse;
if ( u < 0f || u > 1f ) {
return false;
}
var q = Vector3.Cross( toOrigin, first );
var v = Vector3.Dot( direction, q ) * inverse;
if ( v < 0f || u + v > 1f ) {
return false;
}
distance = Vector3.Dot( second, q ) * inverse;
return distance > 1e-4f;
}
}
// The same solve ArchFittingRequest makes when the scene pass hangs the prefab, with the designer's orientation
// nudge composed in front of it - so what the author turns here is what stands in the room.
public static class ArchAimedPrefabFit {
public static Transform Landing( IArchAimedPrefab preset, BBox bounds, Transform mount, BBox into ) {
if ( preset is null ) {
return mount;
}
var rotation = Squared( mount.Rotation * preset.AnchorTurn.ToRotation(), preset.AnchorAlong );
var scale = preset.Scales ? Filled( bounds, into ) : Vector3.One;
return new Transform( mount.Position - rotation * (preset.Anchor * scale), rotation, scale );
}
// The shortest turn carrying the picked edge onto whichever mount axis it already lies nearest - a square
// prefab is left exactly as it is and a slightly-off one is corrected rather than spun.
static Rotation Squared( Rotation nudged, Vector3 along ) {
if ( along.Length < 0.001f ) {
return nudged;
}
var pointing = nudged * along.Normal;
var nearest = ArchAimedPrefabMount.Axes
.SelectMany( axis => new[] { axis, -axis } )
.OrderByDescending( axis => Vector3.Dot( axis, pointing ) )
.First();
return Vector3.Dot( pointing, nearest ) > 0.9999f ? nudged : Rotation.FromToRotation( pointing, nearest ) * nudged;
}
static Vector3 Filled( BBox bounds, BBox into ) {
var room = into.Size;
var size = bounds.Size;
return new Vector3( Fitted( size.x, room.x ), Fitted( size.y, room.y ), Fitted( size.z, room.z ) );
}
static float Fitted( float modelled, float room ) {
return modelled > 0.01f && room > 0.01f ? room / modelled : 1f;
}
}
// The prefab in its mount, orbited and aimed at. Everything the author drew stands here as it was drawn - it is a
// prefab instance, components and all, never a mesh lifted out of one.
public sealed class ArchAimedPrefabStage : SceneRenderingWidget {
readonly Scene stage;
readonly Func<IArchAimedPrefab> read;
readonly Func<BBox> room;
readonly Action<Vector3, Vector3> picked;
GameObject standing;
ArchAimedPrefabGeometry geometry = new();
string showing;
Vector2 lastMouse;
Vector3 hoverAnchor;
Vector3 hoverAlong;
bool hovering;
bool orbiting;
float yaw = 40f;
float pitch = 14f;
float distance = 70f;
public ArchAimedPrefabStage( Widget parent, Func<IArchAimedPrefab> read, Action<Vector3, Vector3> picked,
Func<BBox> room ) : base( parent ) {
this.read = read;
this.picked = picked;
this.room = room;
stage = Scene.CreateEditorScene();
Scene = stage;
MinimumSize = 420;
MouseTracking = true;
using ( stage.Push() ) {
new GameObject( true, "camera" ).GetOrAddComponent<CameraComponent>().BackgroundColor = new Color( 0.075f, 0.08f, 0.095f );
var sun = new GameObject( true, "sun" ).GetOrAddComponent<DirectionalLight>();
sun.WorldRotation = Rotation.From( 42f, -145f, 0f );
sun.LightColor = Color.White * 3.2f;
sun.SkyColor = new Color( 0.38f, 0.43f, 0.52f ) * 2.2f;
sun.Shadows = false;
}
}
public bool Aimed => geometry.Standing;
public void Frame() {
distance = MathF.Max( 24f, geometry.Bounds.Size.Length * 1.6f );
}
[EditorEvent.Frame]
public void OnFrame() {
if ( !Visible || Width < 1f || Height < 1f ) {
Scene = null;
return;
}
Scene = stage;
Restand();
Place();
using ( stage.Push() ) {
stage.EditorTick( RealTime.Now, RealTime.Delta );
var look = new Angles( pitch, yaw, 0f );
var about = room().Center;
stage.Camera.WorldPosition = about - look.Forward * distance;
stage.Camera.WorldRotation = look.ToRotation();
stage.Camera.FieldOfView = 40f;
stage.Camera.ZNear = 1f;
stage.Camera.ZFar = 5000f;
}
Mark();
}
// The instance is rebuilt only when the PREFAB changes: rebuilding it per frame would restart every component
// the author hung on it, which is the one thing this preview exists to show working.
void Restand() {
var wanted = read()?.Prefab ?? "";
if ( wanted == showing ) {
return;
}
showing = wanted;
standing?.Destroy();
standing = null;
geometry = new ArchAimedPrefabGeometry();
if ( string.IsNullOrWhiteSpace( wanted ) || ResourceLibrary.Get<PrefabFile>( wanted ) is not { } file ) {
return;
}
using ( stage.Push() ) {
standing = GameObject.Clone( file );
standing.WorldTransform = Transform.Zero;
}
geometry = ArchAimedPrefabGeometry.Of( standing );
Frame();
}
void Place() {
if ( !standing.IsValid() ) {
return;
}
standing.WorldTransform = ArchAimedPrefabFit.Landing( read(), geometry.Bounds, Transform.Zero, room() );
}
void Mark() {
using ( GizmoInstance.Push() )
using ( Gizmo.Scope( "cabinet_front" ) ) {
Gizmo.Draw.LineThickness = 1.5f;
Gizmo.Draw.Color = Theme.Blue.WithAlpha( 0.55f );
Gizmo.Draw.LineBBox( room() );
var preset = read();
if ( preset is { Anchored: true } ) {
Show( World( preset.Anchor, preset.AnchorAlong ), Theme.Green );
}
if ( hovering ) {
Show( World( hoverAnchor, hoverAlong ), Theme.Yellow );
}
}
}
void Show( (Vector3 Point, Vector3 Along) feature, Color colour ) {
Gizmo.Draw.Color = colour;
Gizmo.Draw.LineSphere( feature.Point, 0.6f );
if ( feature.Along.Length > 0.01f ) {
Gizmo.Draw.LineThickness = 3f;
Gizmo.Draw.Line( feature.Point - feature.Along * 3f, feature.Point + feature.Along * 3f );
Gizmo.Draw.LineThickness = 1.5f;
}
}
(Vector3 Point, Vector3 Along) World( Vector3 anchor, Vector3 along ) {
var placed = standing.IsValid() ? standing.WorldTransform : Transform.Zero;
return (placed.PointToWorld( anchor ), placed.NormalToWorld( along ));
}
// Aimed in the PREFAB's own space, so a pick survives the turn the author gives it a moment later.
bool Reach( Vector2 local, out Vector3 anchor, out Vector3 along ) {
anchor = Vector3.Zero;
along = Vector3.Zero;
if ( !geometry.Standing || !standing.IsValid() ) {
return false;
}
var ray = GetRay( local );
var placed = standing.WorldTransform;
return geometry.Aim( placed.PointToLocal( ray.Position ), placed.NormalToLocal( ray.Forward ).Normal, out anchor, out along );
}
protected override void OnMousePress( MouseEvent e ) {
base.OnMousePress( e );
lastMouse = e.LocalPosition;
orbiting = (e.ButtonState & MouseButtons.Right) != 0;
}
protected override void OnMouseReleased( MouseEvent e ) {
base.OnMouseReleased( e );
orbiting = false;
}
protected override void OnMouseClick( MouseEvent e ) {
base.OnMouseClick( e );
if ( !e.LeftMouseButton || !Reach( e.LocalPosition, out var anchor, out var along ) ) {
return;
}
picked?.Invoke( anchor, along );
}
protected override void OnMouseMove( MouseEvent e ) {
base.OnMouseMove( e );
var delta = e.LocalPosition - lastMouse;
lastMouse = e.LocalPosition;
if ( orbiting && (e.ButtonState & MouseButtons.Right) != 0 ) {
yaw -= delta.x * 0.35f;
pitch = Math.Clamp( pitch + delta.y * 0.25f, -80f, 80f );
return;
}
orbiting = false;
hovering = Reach( e.LocalPosition, out hoverAnchor, out hoverAlong );
}
protected override void OnMouseLeave() {
hovering = false;
}
protected override void OnMouseWheel( WheelEvent e ) {
distance = Math.Clamp( distance * (e.Delta > 0f ? 0.9f : 1.1f), 8f, 600f );
}
public override void OnDestroyed() {
base.OnDestroyed();
stage?.Destroy();
Scene = null;
}
}
// The material groups a prefab's model offers. Read off the compiled model, because a group name that is not on it
// is one the renderer silently ignores - and a picker offering it would look broken rather than wrong.
public static class ArchPrefabGroups {
public static List<string> Offered( string prefab ) {
var found = new List<string>();
if ( string.IsNullOrWhiteSpace( prefab ) || PrefabFile.Load( prefab ) is not { } file ) {
return found;
}
if ( SceneUtility.GetPrefabScene( file ) is not { } source ) {
return found;
}
foreach ( var renderer in source.Components.GetAll<ModelRenderer>( FindMode.EverythingInSelfAndDescendants ) ) {
if ( renderer.Model is not { MaterialGroupCount: > 0 } model ) {
continue;
}
for ( var index = 0; index < model.MaterialGroupCount; index++ ) {
var name = model.GetMaterialGroupName( index );
if ( !string.IsNullOrWhiteSpace( name ) && !found.Contains( name, StringComparer.OrdinalIgnoreCase ) ) {
found.Add( name );
}
}
}
return found;
}
}
// Where a prefab is dropped. A path typed by hand is not a gesture anybody wants twice, so this takes the drag from
// the asset browser and offers the picker as the way in for anyone who would rather browse.
public sealed class ArchPrefabWell : Widget {
readonly Func<string> read;
readonly Action<string> took;
bool inviting;
public ArchPrefabWell( Widget parent, Func<string> read, Action<string> took ) : base( parent ) {
this.read = read;
this.took = took;
AcceptDrops = true;
Cursor = CursorShape.Finger;
FixedHeight = 86f;
}
public override void OnDragHover( DragEvent e ) {
if ( Dropped( e ) is null ) {
return;
}
e.Action = DropAction.Link;
inviting = true;
Update();
}
public override void OnDragLeave() {
inviting = false;
Update();
}
public override void OnDragDrop( DragEvent e ) {
inviting = false;
if ( Dropped( e ) is not { } path ) {
return;
}
e.Action = DropAction.Link;
took( path );
Update();
}
static string Dropped( DragEvent e ) {
var dragged = e.Data.Assets
.Select( asset => asset.AssetPath )
.FirstOrDefault( path => path is not null && path.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase ) );
if ( dragged is not null ) {
return dragged;
}
return e.Data.HasFileOrFolder && e.Data.FileOrFolder.EndsWith( ".prefab", StringComparison.OrdinalIgnoreCase )
? e.Data.FileOrFolder
: null;
}
protected override void OnMouseClick( MouseEvent e ) {
if ( !e.LeftMouseButton ) {
return;
}
var picker = AssetPicker.Create( this, AssetType.Find( "prefab", false ) );
picker.Window.Title = "Prefab";
picker.OnAssetPicked = assets => {
if ( assets.FirstOrDefault() is { } asset ) {
took( asset.Path );
Update();
}
};
picker.Show();
}
protected override void OnPaint() {
Paint.Antialiasing = true;
Paint.TextAntialiasing = true;
var body = LocalRect.Shrink( 1f );
var held = read();
var standing = !string.IsNullOrWhiteSpace( held );
Paint.SetBrushAndPen( Theme.ControlBackground, Theme.Blue.WithAlpha( inviting ? 1f : standing ? 0.35f : 0.18f ) );
Paint.DrawRect( body, 4 );
var art = new Rect( body.Left + 6f, body.Top + 6f, 72f, 72f );
if ( standing && AssetSystem.FindByPath( held )?.GetAssetThumb() is { } thumb ) {
Paint.Draw( art, thumb, 1f );
} else {
Paint.SetPen( Theme.TextControl.WithAlpha( 0.4f ) );
Paint.DrawIcon( art, standing ? "widgets" : "download", 30f );
}
var text = body.Shrink( 86f, 8f, 8f, 8f );
Paint.SetDefaultFont( 8f, 600 );
Paint.SetPen( Theme.Text );
Paint.DrawText( text, standing ? ArchCabinetFronts.Titled( held ) : "Drop a prefab here", TextFlag.LeftTop );
Paint.SetDefaultFont( 7f );
Paint.SetPen( Theme.TextControl.WithAlpha( 0.55f ) );
Paint.DrawText( text.Shrink( 0f, 16f, 0f, 0f ), standing ? held : "or click to browse", TextFlag.LeftTop );
}
}