Editor utility for carving straight stair flights from a drag input. It sketches a stair part from a drag, assigns IDs/names and files carved stairs to a plan, resolves stair geometry against a building, and enumerates solid volumes left by the carve.
using System;
using System.Collections.Generic;
using System.Linq;
using Sandbox;
namespace Sunless.Architecture;
// Nothing here emits a tread — every step is host material that survived the cut.
public static class ArchStairCarve
{
public const float MinRun = 18f;
public static ArchStairPart Cut( ArchPlan plan, ArchCarveDrag drag, ArchKit kit, ArchStairPart template )
{
var flight = Sketch( drag, kit, template );
if ( flight is null )
{
return null;
}
var kinds = ArchKinds.Load();
flight.Id = plan.AllocateId();
flight.Name = $"Steps{plan.CountFiled( ArchKind.CarvedStair, drag.Host, kinds ) + 1}";
plan.File( ArchKind.CarvedStair, drag.Host, flight, kinds );
return flight;
}
// One drag description feeds ghost and cut, so the notch previewed is the notch carved.
public static ArchStairPart Sketch( ArchCarveDrag drag, ArchKit kit, ArchStairPart template )
{
if ( drag is not { Lands: true } )
{
return null;
}
var host = drag.Host;
var from = drag.Foot;
var to = drag.Head;
var span = to - from;
// A square drag keeps its box reading — the perpendicular extent is the width.
var yaw = ArchGridService.Snap( MathF.Atan2( span.y, span.x ).RadianToDegree(), ArchGridService.AngleStep );
var axes = new ArchStairAxes { Yaw = yaw };
var square = MathF.Abs( yaw % 90f ) < 0.01f;
var reach = Vector2.Dot( span, axes.Across );
var length = MathF.Abs( Vector2.Dot( span, axes.Along ) );
if ( length < MinRun )
{
return null;
}
var authored = template?.Width > 1f ? template.Width : kit.StairWidth;
var clear = square && MathF.Abs( reach ) >= MinRun ? MathF.Abs( reach ) : MathF.Max( MinRun, authored );
var rise = host.Rise();
var riser = template?.StepRise > 0.5f ? template.StepRise : kit.StepRise;
var risers = Math.Max( 1, (int)MathF.Ceiling( rise / MathF.Max( 3f, riser ) ) );
var foot = from + axes.Across * (reach * 0.5f);
// The drag IS the flight; a carve never turns, so the shaft holds exactly one lane.
var origin = foot - axes.Across * (clear * 0.5f);
var (core, lanes) = ArchStairCore.Straight( origin, axes.Along, length, clear, rise );
return new ArchStairPart
{
BaseHeight = host.GradeHeight,
Core = core,
Lanes = lanes,
Width = clear,
// The host's rise is fixed, so the riser bends — arriving under the coping is a trip.
StepRise = rise / risers,
// And the going bends with it: the resolve tiles a run into its DRAWN length, so a going
// left at the kit's would cut however many treads fit the drag rather than the climb.
StepGoing = length / risers,
Guard = template?.Guard ?? StairGuard.None,
// A carved flight arrives on the host's own surface, so there is nothing for a landing to be.
TopLanding = false
};
}
// The one place a carved flight resolves — ghost, generator and report read the same steps.
public static ArchStairShape Resolve( IArchCarveHost host, ArchStairPart stair, ArchBuilding building, ArchKit kit )
{
var storey = building?.Rooms?.Where( room => room.Floor == host.Level ) ?? Enumerable.Empty<ArchRoom>();
return ArchStairShape.Resolve( stair, null, kit, storey );
}
// What is left standing under the stepped profile IS the flight.
public static IEnumerable<ArchCarveVolume> Volumes( IArchCarveHost host, ArchStairPart stair, ArchBuilding building, ArchKit kit )
{
var shape = Resolve( host, stair, building, kit );
foreach ( var run in shape.Runs )
{
for ( var step = 0; step < run.Steps; step++ )
{
var footprint = run.Axes.Rect( step * run.Going, (step + 1) * run.Going, 0f, run.Width );
yield return ArchCarveVolume.Over( footprint, run.StepTop( step ), host.TopHeight );
}
}
// A landing is one very wide step — runs alone leave a turn's corner solid.
foreach ( var pad in shape.Pads )
{
yield return ArchCarveVolume.Over( pad.Loop(), pad.Height, host.TopHeight );
}
}
}