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
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@

All notable changes to this project will be documented in this file.

## [1.7.7] - 2026-03-10

### Added
- Hexa pieces can now be exported as a print-ready A4 PDF from the Hexas list
- Choose tile size (Small/Medium/Large) before exporting; last-used size is remembered
- Tiles are arranged in a honeycomb pattern with crop marks for easy cutting
- Each Hexa has a configurable quantity field controlling how many copies appear in the PDF

## [1.7.6] - 2026-03-08

### Added
Expand Down
12 changes: 12 additions & 0 deletions app/Filament/Resources/HexaResource.php
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ public static function form(Form $form): Form
->maxLength(255)
->columnSpanFull(),

Forms\Components\TextInput::make('quantity')
->label('Quantity')
->numeric()
->minValue(1)
->default(1)
->required()
->columnSpanFull(),

Forms\Components\FileUpload::make('image')
->label('Image')
->image()
Expand Down Expand Up @@ -104,6 +112,10 @@ public static function table(Table $table): Table
->badge()
->color('primary'),

Tables\Columns\TextColumn::make('quantity')
->label('Qty')
->sortable(),

Tables\Columns\TextColumn::make('description')
->label('Description')
->limit(50)
Expand Down
33 changes: 33 additions & 0 deletions app/Filament/Resources/HexaResource/Pages/ListHexas.php
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,9 @@
namespace App\Filament\Resources\HexaResource\Pages;

use App\Filament\Resources\HexaResource;
use App\Models\Game;
use Filament\Actions;
use Filament\Forms\Components\Select;
use Filament\Resources\Pages\ListRecords;

class ListHexas extends ListRecords
Expand All @@ -16,6 +18,37 @@ class ListHexas extends ListRecords
protected function getHeaderActions(): array
{
return [
Actions\Action::make('exportPdf')
->label('Export PDF')
->icon('heroicon-o-printer')
->color('warning')
->form([
Select::make('game_id')
->label('Game')
->options(fn () => Game::where('creator_id', auth()->id())->pluck('name', 'id'))
->required()
->searchable()
->preload(),

Select::make('size')
->label('Hexa Size')
->options([
'small' => 'Small (~25mm)',
'medium' => 'Medium (~40mm)',
'large' => 'Large (~60mm)',
])
->default(session('hexa_export_size', 'medium'))
->required(),
])
->action(function (array $data) {
session(['hexa_export_size' => $data['size']]);

return redirect()->route('hexa.export.pdf', [
'game_id' => $data['game_id'],
'size' => $data['size'],
]);
}),

Actions\CreateAction::make(),
];
}
Expand Down
28 changes: 28 additions & 0 deletions app/Http/Controllers/HexaExportController.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
<?php
/**
* Webtech-solutions 2025, All rights reserved.
*/

namespace App\Http\Controllers;

use App\Models\Game;
use App\Services\HexaPdfService;
use Illuminate\Http\Request;

class HexaExportController extends Controller
{
public function export(Request $request)
{
$request->validate([
'game_id' => 'required|exists:games,id',
'size' => 'required|in:small,medium,large',
]);

// Ensure the game belongs to the authenticated user
$game = Game::where('id', $request->game_id)
->where('creator_id', auth()->id())
->firstOrFail();

return app(HexaPdfService::class)->download($game, $request->size);
}
}
1 change: 1 addition & 0 deletions app/Models/Hexa.php
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ class Hexa extends Model
'name',
'description',
'image',
'quantity',
];

public function game()
Expand Down
209 changes: 209 additions & 0 deletions app/Services/HexaPdfService.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
<?php
/**
* Webtech-solutions 2025, All rights reserved.
*/

namespace App\Services;

use App\Models\Game;
use App\Models\Hexa;
use Dompdf\Dompdf;
use Illuminate\Support\Facades\Storage;

class HexaPdfService
{
protected array $sizes = [
'small' => ['ftf_mm' => 25, 'label' => 'Small (~25mm)'],
'medium' => ['ftf_mm' => 40, 'label' => 'Medium (~40mm)'],
'large' => ['ftf_mm' => 60, 'label' => 'Large (~60mm)'],
];

const PAGE_W_PT = 595.28;
const PAGE_H_PT = 841.89;
const MM_TO_PT = 72 / 25.4;

/** @return array<Hexa> */
public function expandHexas(Game $game): array
{
$hexas = Hexa::where('game_id', $game->id)->orderBy('name')->get();
$expanded = [];
foreach ($hexas as $hexa) {
$qty = max(1, (int) ($hexa->quantity ?? 1));
for ($i = 0; $i < $qty; $i++) {
$expanded[] = $hexa;
}
}
return $expanded;
}

protected function resolveImagePath(Hexa $hexa): ?string
{
if (empty($hexa->image)) {
return null;
}
$path = Storage::disk('public')->path($hexa->image);
return file_exists($path) ? $path : null;
}

/**
* Flat-top hex vertices at angle 0°=right.
* Returns [[x,y], ...] in PDF points (top-down coords, dompdf handles y-flip internally).
*/
protected function hexVertices(float $cx, float $cy, float $r): array
{
$pts = [];
for ($i = 0; $i < 6; $i++) {
$angle = deg2rad(60 * $i);
$pts[] = [$cx + $r * cos($angle), $cy + $r * sin($angle)];
}
return $pts;
}

/** Flat [x0,y0,x1,y1,...] array for CPDF polygon() / clipping_polygon() */
protected function hexPts(float $cx, float $cy, float $r): array
{
$flat = [];
foreach ($this->hexVertices($cx, $cy, $r) as [$vx, $vy]) {
$flat[] = $vx;
$flat[] = $vy;
}
return $flat;
}

public function download(Game $game, string $size): \Illuminate\Http\Response
{
$sizeConfig = $this->sizes[$size] ?? $this->sizes['medium'];
$ftfMm = $sizeConfig['ftf_mm'];
$sizeLabel = $sizeConfig['label'];

// Flat-top hex: flat-to-flat (y) = sqrt(3)*r, vertex-to-vertex (x) = 2*r
$rPt = ($ftfMm / sqrt(3)) * self::MM_TO_PT;
$hexWPt = 2 * $rPt; // bounding box width (x: vertex to vertex)
$hexHPt = sqrt(3) * $rPt; // bounding box height (y: flat to flat)
$marginPt = 8 * self::MM_TO_PT;
$headerHPt = 12 * self::MM_TO_PT;
$gapPt = 6 * self::MM_TO_PT;

$usableW = self::PAGE_W_PT - 2 * $marginPt;
$colsPerRow = max(1, (int) floor(($usableW + 0.001) / ($hexWPt + $gapPt)));
$colStepPt = $hexWPt + $gapPt;
$rowStepPt = $hexHPt + $gapPt;

$hexas = $this->expandHexas($game);

// Compute tile positions per page
$tileLayout = [];
$col = $row = $rowsUsed = $pageIdx = 0;

foreach ($hexas as $index => $hexa) {
$rowOffset = 0;
$tileBottom = $headerHPt + $marginPt + $rowsUsed * $rowStepPt + $hexHPt;

if ($tileBottom > self::PAGE_H_PT - $marginPt && $col === 0 && $index > 0) {
$pageIdx++;
$rowsUsed = $row = 0;
$rowOffset = 0;
}

$tileLayout[$pageIdx][] = [
'hexa' => $hexa,
'img' => $this->resolveImagePath($hexa),
'tileX' => $marginPt + $rowOffset + $col * $colStepPt,
'tileY' => $headerHPt + $marginPt + $rowsUsed * $rowStepPt,
];

$col++;
if ($col >= $colsPerRow) {
$col = 0;
$row++;
$rowsUsed++;
}
}

// Build PDF
$dompdf = new Dompdf();
$dompdf->loadHtml('<html><body></body></html>');
$dompdf->setPaper('a4', 'portrait');
$dompdf->render();
$canvas = $dompdf->getCanvas();

$font = $dompdf->getFontMetrics()->getFont('Helvetica');
$fontSize = max(8.0, $ftfMm * 0.22);

foreach ($tileLayout as $pIdx => $tiles) {
if ($pIdx > 0) {
$canvas->new_page();
}
$this->drawHeader($canvas, $font, $game->name, $sizeLabel, $marginPt, $headerHPt, $fontSize);

foreach ($tiles as $tile) {
$this->drawTile($canvas, $font, $tile['hexa'], $tile['img'], $tile['tileX'], $tile['tileY'], $hexWPt, $hexHPt, $rPt, $fontSize);
}
}

$filename = 'hexas-' . str($game->name)->slug() . '-' . $size . '.pdf';

return response($canvas->output(), 200, [
'Content-Type' => 'application/pdf',
'Content-Disposition' => 'attachment; filename="' . $filename . '"',
]);
}

protected function drawHeader(
\Dompdf\Adapter\CPDF $canvas,
string $font,
string $gameName,
string $sizeLabel,
float $marginPt,
float $headerHPt,
float $fontSize
): void {
$canvas->text($marginPt, $marginPt + 2, $gameName, $font, $fontSize + 2, [0, 0, 0]);
$canvas->text($marginPt, $marginPt + 2 + ($fontSize + 2) * 1.4, $sizeLabel, $font, $fontSize, [0.4, 0.4, 0.4]);
$canvas->line($marginPt, $headerHPt - 1, self::PAGE_W_PT - $marginPt, $headerHPt - 1, [0.7, 0.7, 0.7], 0.5);
}

protected function drawTile(
\Dompdf\Adapter\CPDF $canvas,
string $font,
Hexa $hexa,
?string $imagePath,
float $tileX,
float $tileY,
float $hexWPt,
float $hexHPt,
float $rPt,
float $fontSize
): void {
$cx = $tileX + $hexWPt / 2;
$cy = $tileY + $hexHPt / 2;
$pts = $this->hexPts($cx, $cy, $rPt);

// 1. Grey background fill
$canvas->polygon($pts, [0.82, 0.82, 0.82], null, [], true);

// 2. Image clipped to hex shape via CPDF clipping path
if ($imagePath) {
$canvas->save();
$canvas->clipping_polygon($pts);
$canvas->image($imagePath, $tileX, $tileY, $hexWPt, $hexHPt);
$canvas->clipping_end();
$canvas->restore();
}

// 3. Outline on top
$canvas->polygon($pts, [0.15, 0.15, 0.15], 1.2, [], false);

// 4. Centred name
$textW = $canvas->get_text_width($hexa->name, $font, $fontSize);
$canvas->text(
$cx - $textW / 2,
$cy - $fontSize * 0.35,
$hexa->name,
$font,
$fontSize,
$imagePath ? [1.0, 1.0, 1.0] : [0.1, 0.1, 0.1]
);
}

}
4 changes: 2 additions & 2 deletions config/app_version.php
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,9 @@
|
*/

'version' => env('APP_VERSION', 'v1.7.5'),
'version' => env('APP_VERSION', 'v1.7.7'),

'release_date' => '2026-03-08',
'release_date' => '2026-03-10',

'status' => 'stable', // alpha, beta, stable

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
<?php
/**
* Webtech-solutions 2025, All rights reserved.
*/

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

return new class extends Migration
{
public function up(): void
{
Schema::table('hexas', function (Blueprint $table) {
$table->unsignedInteger('quantity')->default(1)->after('image');
});
}

public function down(): void
{
Schema::table('hexas', function (Blueprint $table) {
$table->dropColumn('quantity');
});
}
};
Loading
Loading