Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions src/ShedBuilder.Api/Controllers/DesignsController.cs
Original file line number Diff line number Diff line change
Expand Up @@ -96,6 +96,7 @@ public async Task<ActionResult<DesignResponse>> Create(CreateDesignRequest reque
HeightInches = request.HeightInches,
RoofPitch = request.RoofPitch,
RoofType = request.RoofType,
RoofOverhangInches = request.RoofOverhangInches,
Openings = request.Openings?.Select(o => new Opening
{
Type = o.Type,
Expand Down Expand Up @@ -136,6 +137,7 @@ public async Task<ActionResult<DesignResponse>> Update(Guid id, UpdateDesignRequ
if (request.HeightInches.HasValue) design.HeightInches = request.HeightInches.Value;
if (request.RoofPitch.HasValue) design.RoofPitch = request.RoofPitch.Value;
if (request.RoofType.HasValue) design.RoofType = request.RoofType.Value;
if (request.RoofOverhangInches.HasValue) design.RoofOverhangInches = request.RoofOverhangInches.Value;
if (request.Openings != null)
{
design.Openings = request.Openings.Select(o => new Opening
Expand Down Expand Up @@ -301,6 +303,7 @@ public async Task<ActionResult<VersionResponse>> CreateVersion(Guid id, CreateVe
HeightInches = design.HeightInches,
RoofPitch = design.RoofPitch,
RoofType = design.RoofType,
RoofOverhangInches = design.RoofOverhangInches,
Openings = design.Openings.Select(o => new Opening
{
Type = o.Type,
Expand Down Expand Up @@ -353,6 +356,7 @@ public async Task<ActionResult<DesignResponse>> RestoreVersion(Guid id, Guid vid
design.HeightInches = version.HeightInches;
design.RoofPitch = version.RoofPitch;
design.RoofType = version.RoofType;
design.RoofOverhangInches = version.RoofOverhangInches;
design.Openings = version.Openings.Select(o => new Opening
{
Type = o.Type,
Expand Down Expand Up @@ -380,6 +384,7 @@ public async Task<ActionResult<DesignResponse>> RestoreVersion(Guid id, Guid vid
HeightInches = d.HeightInches,
RoofPitch = d.RoofPitch,
RoofType = d.RoofType,
RoofOverhangInches = d.RoofOverhangInches,
Openings = d.Openings.Select(o => new OpeningDto
{
Type = o.Type,
Expand Down Expand Up @@ -464,6 +469,7 @@ public async Task<ActionResult<DesignResponse>> RestoreVersion(Guid id, Guid vid
HeightInches = v.HeightInches,
RoofPitch = v.RoofPitch,
RoofType = v.RoofType,
RoofOverhangInches = v.RoofOverhangInches,
Openings = v.Openings.Select(o => new OpeningDto
{
Type = o.Type,
Expand Down
3 changes: 3 additions & 0 deletions src/ShedBuilder.Api/Models/Design.cs
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,9 @@ public class Design
[Column("roof_type")]
public RoofType RoofType { get; set; }

[Column("roof_overhang_inches")]
public int RoofOverhangInches { get; set; } = 12;

[Column("user_id")]
public Guid UserId { get; set; }

Expand Down
3 changes: 3 additions & 0 deletions src/ShedBuilder.Api/Models/DesignVersion.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,9 @@ public class DesignVersion
[Column("roof_type")]
public RoofType RoofType { get; set; }

[Column("roof_overhang_inches")]
public int RoofOverhangInches { get; set; } = 12;

[Column("openings", TypeName = "jsonb")]
public List<Opening> Openings { get; set; } = new();

Expand Down
8 changes: 8 additions & 0 deletions src/ShedBuilder.Api/Models/Dtos.cs
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,9 @@ public record CreateDesignRequest : IValidatableObject

public RoofType RoofType { get; init; } = RoofType.Gable;

[Range(0, 36)]
public int RoofOverhangInches { get; init; } = 12;

public List<OpeningDto>? Openings { get; init; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
Expand Down Expand Up @@ -68,6 +71,9 @@ public record UpdateDesignRequest : IValidatableObject

public RoofType? RoofType { get; init; }

[Range(0, 36)]
public int? RoofOverhangInches { get; init; }

public List<OpeningDto>? Openings { get; init; }

public IEnumerable<ValidationResult> Validate(ValidationContext validationContext)
Expand All @@ -89,6 +95,7 @@ public record DesignResponse
public int HeightInches { get; init; }
public decimal RoofPitch { get; init; }
public RoofType RoofType { get; init; }
public int RoofOverhangInches { get; init; }
public List<OpeningDto> Openings { get; init; } = new();
public DateTime CreatedAt { get; init; }
public DateTime UpdatedAt { get; init; }
Expand Down Expand Up @@ -170,6 +177,7 @@ public record VersionResponse
public int HeightInches { get; init; }
public decimal RoofPitch { get; init; }
public RoofType RoofType { get; init; }
public int RoofOverhangInches { get; init; }
public List<OpeningDto> Openings { get; init; } = new();
public DateTime CreatedAt { get; init; }
}
Expand Down
29 changes: 17 additions & 12 deletions src/ShedBuilder.Api/Services/BomCalculator.cs
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ public BomResponse Calculate(Design design)

items.AddRange(CalculateFloor(widthInches, depthInches));
items.AddRange(CalculateWalls(widthInches, depthInches, heightInches, design.Openings));
items.AddRange(CalculateRoof(widthInches, depthInches, roofPitch, design.RoofType));
items.AddRange(CalculateRoof(widthInches, depthInches, roofPitch, design.RoofType, (double)design.RoofOverhangInches));
items.AddRange(CalculateHardware(widthInches, depthInches, heightInches, design.RoofType));
items.AddRange(CalculateOpenings(design.Openings));

Expand Down Expand Up @@ -131,19 +131,22 @@ private List<BomItem> CalculateWalls(double widthIn, double depthIn, double heig
return items;
}

private List<BomItem> CalculateRoof(double widthIn, double depthIn, double roofPitch, RoofType roofType)
private List<BomItem> CalculateRoof(double widthIn, double depthIn, double roofPitch, RoofType roofType, double overhangIn)
{
var items = new List<BomItem>();

if (roofType == RoofType.Gable)
{
// Gable roof: ridge runs along depth, rafters span half the width
// Gable roof: ridge runs along depth, rafters span half the width + overhang
var halfSpan = widthIn / 2.0;
var rise = halfSpan * roofPitch / 12.0;
var rafterLength = Math.Sqrt(halfSpan * halfSpan + rise * rise);
var rafterSpan = halfSpan + overhangIn;
var rafterRise = rafterSpan * roofPitch / 12.0;
var rafterLength = Math.Sqrt(rafterSpan * rafterSpan + rafterRise * rafterRise);

// Rafters at 16" OC on each side
var raftersPerSide = (int)Math.Ceiling(depthIn / 16.0) + 1;
var roofDepth = depthIn + 2 * overhangIn;
var raftersPerSide = (int)Math.Ceiling(roofDepth / 16.0) + 1;
items.Add(new BomItem
{
Material = "Framing lumber",
Expand All @@ -157,14 +160,14 @@ private List<BomItem> CalculateRoof(double widthIn, double depthIn, double roofP
items.Add(new BomItem
{
Material = "Framing lumber",
Dimensions = $"2×8 × {LengthLabel(depthIn)}",
Dimensions = $"2×8 × {LengthLabel(roofDepth)}",
Quantity = 1,
Unit = "pieces",
Category = "Roof"
});

// Roof sheathing (both sides)
var roofSqFtPerSide = (rafterLength * depthIn) / 144.0;
var roofSqFtPerSide = (rafterLength * roofDepth) / 144.0;
var roofSheets = (int)Math.Ceiling(roofSqFtPerSide * 2 / 32.0);
items.Add(new BomItem
{
Expand All @@ -189,11 +192,13 @@ private List<BomItem> CalculateRoof(double widthIn, double depthIn, double roofP
}
else // LeanTo
{
// Lean-to: single slope from high side to low side across width
var rise = widthIn * roofPitch / 12.0;
var rafterLength = Math.Sqrt(widthIn * widthIn + rise * rise);
// Lean-to: single slope from high side to low side across width + overhang
var rafterSpan = widthIn + overhangIn;
var rafterRise = rafterSpan * roofPitch / 12.0;
var rafterLength = Math.Sqrt(rafterSpan * rafterSpan + rafterRise * rafterRise);

var rafterCount = (int)Math.Ceiling(depthIn / 16.0) + 1;
var roofDepth = depthIn + 2 * overhangIn;
var rafterCount = (int)Math.Ceiling(roofDepth / 16.0) + 1;
items.Add(new BomItem
{
Material = "Framing lumber",
Expand All @@ -204,7 +209,7 @@ private List<BomItem> CalculateRoof(double widthIn, double depthIn, double roofP
});

// Roof sheathing
var roofSqFt = (rafterLength * depthIn) / 144.0;
var roofSqFt = (rafterLength * roofDepth) / 144.0;
var roofSheets = (int)Math.Ceiling(roofSqFt / 32.0);
items.Add(new BomItem
{
Expand Down
2 changes: 2 additions & 0 deletions src/ShedBuilder.Api/Services/PdfExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ public byte[] Export(Design design, CostResponse costResponse)
table.Cell().Text($"{design.RoofPitch}/12");
table.Cell().Text("Roof Type:").SemiBold();
table.Cell().Text(design.RoofType.ToString());
table.Cell().Text("Roof Overhang:").SemiBold();
table.Cell().Text($"{design.RoofOverhangInches}\"");
});

// Openings
Expand Down
32 changes: 17 additions & 15 deletions src/ShedBuilder.Api/Services/StlExporter.cs
Original file line number Diff line number Diff line change
Expand Up @@ -56,30 +56,32 @@ public byte[] Export(Design design)
new Vec3(1, 0, 0)));

// Roof
var oh = (float)design.RoofOverhangInches;

if (design.RoofType == RoofType.Gable)
{
var rise = width / 2f * pitch / 12f;
var ridgeY = height + rise;
var ridgeX = width / 2f;

// Left roof slope
// Left roof slope (extended by overhang on all edges)
triangles.AddRange(MakeQuad(
new Vec3(0, height, 0), new Vec3(ridgeX, ridgeY, 0),
new Vec3(ridgeX, ridgeY, depth), new Vec3(0, height, depth),
ComputeNormal(new Vec3(0, height, 0), new Vec3(ridgeX, ridgeY, 0), new Vec3(ridgeX, ridgeY, depth))));
new Vec3(-oh, height, -oh), new Vec3(ridgeX, ridgeY, -oh),
new Vec3(ridgeX, ridgeY, depth + oh), new Vec3(-oh, height, depth + oh),
ComputeNormal(new Vec3(-oh, height, 0), new Vec3(ridgeX, ridgeY, 0), new Vec3(ridgeX, ridgeY, depth))));

// Right roof slope
// Right roof slope (extended by overhang on all edges)
triangles.AddRange(MakeQuad(
new Vec3(ridgeX, ridgeY, 0), new Vec3(width, height, 0),
new Vec3(width, height, depth), new Vec3(ridgeX, ridgeY, depth),
ComputeNormal(new Vec3(ridgeX, ridgeY, 0), new Vec3(width, height, 0), new Vec3(width, height, depth))));
new Vec3(ridgeX, ridgeY, -oh), new Vec3(width + oh, height, -oh),
new Vec3(width + oh, height, depth + oh), new Vec3(ridgeX, ridgeY, depth + oh),
ComputeNormal(new Vec3(ridgeX, ridgeY, 0), new Vec3(width + oh, height, 0), new Vec3(width + oh, height, depth))));

// Front gable triangle
// Front gable triangle (stays at wall face)
triangles.Add(new Triangle(
ComputeNormal(new Vec3(0, height, 0), new Vec3(width, height, 0), new Vec3(ridgeX, ridgeY, 0)),
new Vec3(0, height, 0), new Vec3(width, height, 0), new Vec3(ridgeX, ridgeY, 0)));

// Back gable triangle
// Back gable triangle (stays at wall face)
triangles.Add(new Triangle(
ComputeNormal(new Vec3(width, height, depth), new Vec3(0, height, depth), new Vec3(ridgeX, ridgeY, depth)),
new Vec3(width, height, depth), new Vec3(0, height, depth), new Vec3(ridgeX, ridgeY, depth)));
Expand All @@ -89,18 +91,18 @@ public byte[] Export(Design design)
var rise = width * pitch / 12f;
var highY = height + rise;

// Single slope from left (high) to right (low at wall height)
// Single slope (extended by overhang)
triangles.AddRange(MakeQuad(
new Vec3(0, highY, 0), new Vec3(width, height, 0),
new Vec3(width, height, depth), new Vec3(0, highY, depth),
ComputeNormal(new Vec3(0, highY, 0), new Vec3(width, height, 0), new Vec3(width, height, depth))));
new Vec3(-oh, highY, -oh), new Vec3(width + oh, height, -oh),
new Vec3(width + oh, height, depth + oh), new Vec3(-oh, highY, depth + oh),
ComputeNormal(new Vec3(-oh, highY, 0), new Vec3(width + oh, height, 0), new Vec3(width + oh, height, depth))));

// Front triangle fill
triangles.Add(new Triangle(
new Vec3(0, 0, -1),
new Vec3(0, height, 0), new Vec3(0, highY, 0), new Vec3(width, height, 0)));

// Back triangle fill (note: this is a simplification)
// Back triangle fill
triangles.Add(new Triangle(
new Vec3(0, 0, 1),
new Vec3(width, height, depth), new Vec3(0, highY, depth), new Vec3(0, height, depth)));
Expand Down
11 changes: 11 additions & 0 deletions src/shed-builder-ui/src/components/DesignPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,17 @@ export default memo(function DesignPanel({ design, onChange, saveStatus }: Props
</Select>
</FormControl>

<TextField
label="Roof Overhang (in)"
type="number"
size="small"
fullWidth
value={design.roofOverhangInches}
onChange={(e) => onChange({ roofOverhangInches: Number(e.target.value) })}
inputProps={{ min: 0, max: 36, step: 1 }}
sx={{ mb: 2 }}
/>

<Divider sx={{ my: 2 }} />

<Stack direction="row" justifyContent="space-between" alignItems="center" mb={1}>
Expand Down
25 changes: 14 additions & 11 deletions src/shed-builder-ui/src/components/ShedViewer3D.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -225,31 +225,33 @@ function ShedGeometry({ design }: Props) {

{/* Roof */}
{design.roofType === 'Gable' ? (
<GableRoof w={w} d={d} h={h} pitch={pitch} />
<GableRoof w={w} d={d} h={h} pitch={pitch} oh={scale(design.roofOverhangInches ?? 12)} />
) : (
<LeanToRoof w={w} d={d} h={h} pitch={pitch} />
<LeanToRoof w={w} d={d} h={h} pitch={pitch} oh={scale(design.roofOverhangInches ?? 12)} />
)}
</group>
);
}

function GableRoof({ w, d, h, pitch }: { w: number; d: number; h: number; pitch: number }) {
function GableRoof({ w, d, h, pitch, oh }: { w: number; d: number; h: number; pitch: number; oh: number }) {
const halfW = w / 2;
const rise = (halfW * pitch) / 12;
const rafterLen = Math.sqrt(halfW * halfW + rise * rise);
const rafterSpan = halfW + oh;
const rafterRise = (rafterSpan * pitch) / 12;
const rafterLen = Math.sqrt(rafterSpan * rafterSpan + rafterRise * rafterRise);
const angle = Math.atan2(rise, halfW);

return (
<group>
{/* Left slope */}
<mesh position={[halfW / 2, h + rise / 2, d / 2]} rotation={[0, 0, angle]} castShadow>
<boxGeometry args={[rafterLen, 0.08, d + 0.2]} />
<mesh position={[(halfW - oh) / 2, h + rise / 2, d / 2]} rotation={[0, 0, angle]} castShadow>
<boxGeometry args={[rafterLen, 0.08, d + 2 * oh]} />
<meshStandardMaterial color="#8B0000" />
</mesh>

{/* Right slope */}
<mesh position={[w - halfW / 2, h + rise / 2, d / 2]} rotation={[0, 0, -angle]} castShadow>
<boxGeometry args={[rafterLen, 0.08, d + 0.2]} />
<mesh position={[w - (halfW - oh) / 2, h + rise / 2, d / 2]} rotation={[0, 0, -angle]} castShadow>
<boxGeometry args={[rafterLen, 0.08, d + 2 * oh]} />
<meshStandardMaterial color="#8B0000" />
</mesh>

Expand Down Expand Up @@ -277,15 +279,16 @@ function GableTriangle({ w, rise }: { w: number; rise: number }) {
return <shapeGeometry args={[shape]} />;
}

function LeanToRoof({ w, d, h, pitch }: { w: number; d: number; h: number; pitch: number }) {
function LeanToRoof({ w, d, h, pitch, oh }: { w: number; d: number; h: number; pitch: number; oh: number }) {
const rise = (w * pitch) / 12;
const rafterLen = Math.sqrt(w * w + rise * rise);
const rafterSpan = w + oh;
const rafterLen = Math.sqrt(rafterSpan * rafterSpan + ((rafterSpan * pitch) / 12) ** 2);
const angle = Math.atan2(rise, w);

return (
<group>
<mesh position={[w / 2, h + rise / 2, d / 2]} rotation={[0, 0, -angle]} castShadow>
<boxGeometry args={[rafterLen, 0.08, d + 0.2]} />
<boxGeometry args={[rafterLen, 0.08, d + 2 * oh]} />
<meshStandardMaterial color="#8B0000" />
</mesh>

Expand Down
12 changes: 12 additions & 0 deletions src/shed-builder-ui/src/components/__tests__/DesignPanel.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ const mockDesign: Design = {
heightInches: 0,
roofPitch: 4,
roofType: 'Gable',
roofOverhangInches: 12,
openings: [],
createdAt: '2024-01-01T00:00:00Z',
updatedAt: '2024-01-01T00:00:00Z',
Expand Down Expand Up @@ -115,6 +116,17 @@ describe('DesignPanel', () => {
expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ roofPitch: expect.any(Number) }));
});

it('calls onChange when roof overhang is changed', async () => {
const onChange = vi.fn();
render(<DesignPanel design={mockDesign} onChange={onChange} saveStatus="idle" />);

const overhangInput = screen.getByRole('spinbutton', { name: /roof overhang/i });
await userEvent.clear(overhangInput);
await userEvent.type(overhangInput, '6');

expect(onChange).toHaveBeenCalledWith(expect.objectContaining({ roofOverhangInches: expect.any(Number) }));
});

it('renders with LeanTo roof design', () => {
const leanToDesign = { ...mockDesign, roofType: 'LeanTo' as const };
render(<DesignPanel design={leanToDesign} onChange={vi.fn()} saveStatus="idle" />);
Expand Down
4 changes: 4 additions & 0 deletions src/shed-builder-ui/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Design {
heightInches: number;
roofPitch: number;
roofType: RoofType;
roofOverhangInches: number;
openings: Opening[];
createdAt: string;
updatedAt: string;
Expand All @@ -37,6 +38,7 @@ export interface CreateDesignRequest {
heightInches?: number;
roofPitch?: number;
roofType?: RoofType;
roofOverhangInches?: number;
openings?: Opening[];
}

Expand All @@ -50,6 +52,7 @@ export interface UpdateDesignRequest {
heightInches?: number;
roofPitch?: number;
roofType?: RoofType;
roofOverhangInches?: number;
openings?: Opening[];
}

Expand Down Expand Up @@ -95,6 +98,7 @@ export interface DesignVersion {
heightInches: number;
roofPitch: number;
roofType: RoofType;
roofOverhangInches: number;
openings: Opening[];
createdAt: string;
}
Expand Down
Loading
Loading