@fieldnotes/core 0.46.1 → 0.48.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -69,6 +69,7 @@ __export(index_exports, {
69
69
  getHexCellsInCone: () => getHexCellsInCone,
70
70
  getHexCellsInLine: () => getHexCellsInLine,
71
71
  getHexCellsInRadius: () => getHexCellsInRadius,
72
+ getHexCellsInRectangle: () => getHexCellsInRectangle,
72
73
  getHexCellsInSquare: () => getHexCellsInSquare,
73
74
  getHexDistance: () => getHexDistance,
74
75
  isNearBezier: () => isNearBezier,
@@ -983,6 +984,26 @@ function getTemplateBounds(el) {
983
984
  const maxY = Math.max(y0, y1, y2, y3);
984
985
  return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
985
986
  }
987
+ case "rectangle": {
988
+ const halfW = (el.width ?? 0) / 2;
989
+ const cos = Math.cos(el.angle);
990
+ const sin = Math.sin(el.angle);
991
+ const perpX = -sin * halfW;
992
+ const perpY = cos * halfW;
993
+ const x0 = cx + perpX;
994
+ const y0 = cy + perpY;
995
+ const x1 = cx + r * cos + perpX;
996
+ const y1 = cy + r * sin + perpY;
997
+ const x2 = cx + r * cos - perpX;
998
+ const y2 = cy + r * sin - perpY;
999
+ const x3 = cx - perpX;
1000
+ const y3 = cy - perpY;
1001
+ const minX = Math.min(x0, x1, x2, x3);
1002
+ const minY = Math.min(y0, y1, y2, y3);
1003
+ const maxX = Math.max(x0, x1, x2, x3);
1004
+ const maxY = Math.max(y0, y1, y2, y3);
1005
+ return { x: minX, y: minY, w: maxX - minX, h: maxY - minY };
1006
+ }
986
1007
  }
987
1008
  }
988
1009
  function transferStrokeBounds(prev, next) {
@@ -3286,6 +3307,46 @@ function getHexCellsInLine(center2, angle, radiusCells, cellSize, orientation) {
3286
3307
  }
3287
3308
  return cells;
3288
3309
  }
3310
+ function getHexCellsInRectangle(center2, angle, lengthCells, widthCells, cellSize, orientation) {
3311
+ const nLen = Math.round(lengthCells);
3312
+ const wCells = Math.max(1, Math.round(widthCells));
3313
+ const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
3314
+ const cube = offsetToCube(off.col, off.row, orientation);
3315
+ const centerPixel = offsetToPixel(off.col, off.row, cellSize, orientation);
3316
+ if (nLen <= 0) return [centerPixel];
3317
+ const vertexOffset = orientation === "pointy" ? Math.PI / 6 : 0;
3318
+ const step = Math.PI / 3;
3319
+ const snappedAngle = Math.round((angle - vertexOffset) / step) * step + vertexOffset;
3320
+ const cos = Math.cos(snappedAngle);
3321
+ const sin = Math.sin(snappedAngle);
3322
+ const snapUnit = Math.sqrt(3) * cellSize;
3323
+ const lineLength = nLen * snapUnit;
3324
+ const halfWidth = wCells * snapUnit / 2 + 1e-6;
3325
+ const iterM = nLen + Math.ceil(wCells / 2) + 1;
3326
+ const cells = [];
3327
+ for (let dq = -iterM; dq <= iterM; dq++) {
3328
+ const rMin = Math.max(-iterM, -dq - iterM);
3329
+ const rMax = Math.min(iterM, -dq + iterM);
3330
+ for (let dr = rMin; dr <= rMax; dr++) {
3331
+ const absQ = cube.q + dq;
3332
+ const absR = cube.r + dr;
3333
+ const pixel = offsetToPixel(
3334
+ cubeToOffset(absQ, absR, orientation).col,
3335
+ cubeToOffset(absQ, absR, orientation).row,
3336
+ cellSize,
3337
+ orientation
3338
+ );
3339
+ const dx = pixel.x - centerPixel.x;
3340
+ const dy = pixel.y - centerPixel.y;
3341
+ const along = dx * cos + dy * sin;
3342
+ const perp = Math.abs(-dx * sin + dy * cos);
3343
+ if (along >= -snapUnit * 0.1 && along <= lineLength + snapUnit * 0.1 && perp <= halfWidth) {
3344
+ cells.push(pixel);
3345
+ }
3346
+ }
3347
+ }
3348
+ return cells;
3349
+ }
3289
3350
  function getHexCellsInSquare(center2, radiusCells, cellSize, orientation) {
3290
3351
  const n2 = Math.round(radiusCells);
3291
3352
  const off = pixelToOffset(center2.x, center2.y, cellSize, orientation);
@@ -3324,6 +3385,48 @@ function drawHexPath(ctx, cx, cy, cellSize, orientation) {
3324
3385
  ctx.closePath();
3325
3386
  }
3326
3387
 
3388
+ // src/elements/renderers/template-measure.ts
3389
+ function renderTemplateFeetLabel(ctx, p) {
3390
+ if (p.feet <= 0) return;
3391
+ const directional = p.templateShape === "cone" || p.templateShape === "line" || p.templateShape === "rectangle";
3392
+ const dir = directional ? p.angle : 0;
3393
+ const length = p.templateShape === "square" ? p.radius / 2 : p.radius;
3394
+ const cos = Math.cos(dir);
3395
+ const sin = Math.sin(dir);
3396
+ const start = p.position;
3397
+ const end = { x: start.x + length * cos, y: start.y + length * sin };
3398
+ const midX = start.x + length / 2 * cos;
3399
+ const midY = start.y + length / 2 * sin;
3400
+ ctx.save();
3401
+ ctx.globalAlpha = 1;
3402
+ ctx.beginPath();
3403
+ ctx.setLineDash([4, 4]);
3404
+ ctx.strokeStyle = p.color;
3405
+ ctx.lineWidth = 1.5;
3406
+ ctx.moveTo(start.x, start.y);
3407
+ ctx.lineTo(end.x, end.y);
3408
+ ctx.stroke();
3409
+ ctx.setLineDash([]);
3410
+ const label = `${Math.round(p.feet)} ft`;
3411
+ const fontSize = Math.max(10, Math.min(14, p.radius * 0.15));
3412
+ ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
3413
+ ctx.textAlign = "center";
3414
+ ctx.textBaseline = "bottom";
3415
+ const textY = midY - 4;
3416
+ const metrics = ctx.measureText(label);
3417
+ const padX = 4;
3418
+ const padY = 2;
3419
+ const textW = metrics.width + padX * 2;
3420
+ const textH = fontSize + padY * 2;
3421
+ ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
3422
+ ctx.beginPath();
3423
+ ctx.roundRect(midX - textW / 2, textY - textH, textW, textH, 3);
3424
+ ctx.fill();
3425
+ ctx.fillStyle = p.color;
3426
+ ctx.fillText(label, midX, textY - padY);
3427
+ ctx.restore();
3428
+ }
3429
+
3327
3430
  // src/elements/renderers/template-renderer.ts
3328
3431
  function renderTemplate(ctx, template, store) {
3329
3432
  const grid = store?.getElementsByType("grid")[0];
@@ -3347,9 +3450,6 @@ function renderGeometricTemplate(ctx, template) {
3347
3450
  ctx.arc(cx, cy, r, 0, Math.PI * 2);
3348
3451
  ctx.fill();
3349
3452
  ctx.stroke();
3350
- if (template.radiusFeet != null && template.radiusFeet > 0) {
3351
- renderRadiusMarker(ctx, cx, cy, r, template.radiusFeet);
3352
- }
3353
3453
  break;
3354
3454
  case "square":
3355
3455
  ctx.fillRect(cx - r / 2, cy - r / 2, r, r);
@@ -3381,6 +3481,32 @@ function renderGeometricTemplate(ctx, template) {
3381
3481
  ctx.stroke();
3382
3482
  break;
3383
3483
  }
3484
+ case "rectangle": {
3485
+ const halfW = (template.width ?? 0) / 2;
3486
+ const cos = Math.cos(template.angle);
3487
+ const sin = Math.sin(template.angle);
3488
+ const perpX = -sin * halfW;
3489
+ const perpY = cos * halfW;
3490
+ ctx.beginPath();
3491
+ ctx.moveTo(cx + perpX, cy + perpY);
3492
+ ctx.lineTo(cx + r * cos + perpX, cy + r * sin + perpY);
3493
+ ctx.lineTo(cx + r * cos - perpX, cy + r * sin - perpY);
3494
+ ctx.lineTo(cx - perpX, cy - perpY);
3495
+ ctx.closePath();
3496
+ ctx.fill();
3497
+ ctx.stroke();
3498
+ break;
3499
+ }
3500
+ }
3501
+ if (template.radiusFeet != null && template.radiusFeet > 0) {
3502
+ renderTemplateFeetLabel(ctx, {
3503
+ position: template.position,
3504
+ radius: template.radius,
3505
+ angle: template.angle,
3506
+ templateShape: template.templateShape,
3507
+ feet: template.radiusFeet,
3508
+ color: template.strokeColor
3509
+ });
3384
3510
  }
3385
3511
  ctx.restore();
3386
3512
  }
@@ -3402,6 +3528,18 @@ function renderHexTemplate(ctx, template, cellSize, orientation) {
3402
3528
  case "square":
3403
3529
  cells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
3404
3530
  break;
3531
+ case "rectangle": {
3532
+ const widthCells = (template.width ?? 0) / snapUnit;
3533
+ cells = getHexCellsInRectangle(
3534
+ center2,
3535
+ template.angle,
3536
+ radiusCells,
3537
+ widthCells,
3538
+ cellSize,
3539
+ orientation
3540
+ );
3541
+ break;
3542
+ }
3405
3543
  }
3406
3544
  ctx.save();
3407
3545
  ctx.globalAlpha = template.opacity;
@@ -3428,44 +3566,18 @@ function renderHexTemplate(ctx, template, cellSize, orientation) {
3428
3566
  ctx.lineWidth = template.strokeWidth;
3429
3567
  ctx.stroke();
3430
3568
  }
3431
- if (template.templateShape === "circle" && template.radiusFeet != null && template.radiusFeet > 0) {
3432
- const r = template.radius;
3433
- renderRadiusMarker(ctx, center2.x, center2.y, r, template.radiusFeet);
3569
+ if (template.radiusFeet != null && template.radiusFeet > 0) {
3570
+ renderTemplateFeetLabel(ctx, {
3571
+ position: template.position,
3572
+ radius: template.radius,
3573
+ angle: template.angle,
3574
+ templateShape: template.templateShape,
3575
+ feet: template.radiusFeet,
3576
+ color: template.strokeColor
3577
+ });
3434
3578
  }
3435
3579
  ctx.restore();
3436
3580
  }
3437
- function renderRadiusMarker(ctx, cx, cy, r, feet) {
3438
- const markerColor = ctx.strokeStyle;
3439
- ctx.save();
3440
- ctx.globalAlpha = 1;
3441
- ctx.beginPath();
3442
- ctx.setLineDash([4, 4]);
3443
- ctx.strokeStyle = markerColor;
3444
- ctx.lineWidth = 1.5;
3445
- ctx.moveTo(cx, cy);
3446
- ctx.lineTo(cx + r, cy);
3447
- ctx.stroke();
3448
- ctx.setLineDash([]);
3449
- const label = `${Math.round(feet)} ft`;
3450
- const fontSize = Math.max(10, Math.min(14, r * 0.15));
3451
- ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
3452
- ctx.textAlign = "center";
3453
- ctx.textBaseline = "bottom";
3454
- const textX = cx + r / 2;
3455
- const textY = cy - 4;
3456
- const metrics = ctx.measureText(label);
3457
- const padX = 4;
3458
- const padY = 2;
3459
- const textW = metrics.width + padX * 2;
3460
- const textH = fontSize + padY * 2;
3461
- ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
3462
- ctx.beginPath();
3463
- ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
3464
- ctx.fill();
3465
- ctx.fillStyle = markerColor;
3466
- ctx.fillText(label, textX, textY - padY);
3467
- ctx.restore();
3468
- }
3469
3581
 
3470
3582
  // src/elements/grid-renderer.ts
3471
3583
  function getSquareGridLines(bounds, cellSize) {
@@ -4004,7 +4116,8 @@ function createTemplate(input) {
4004
4116
  opacity: input.opacity ?? 0.6,
4005
4117
  feetPerCell: input.feetPerCell,
4006
4118
  radiusFeet: input.radiusFeet,
4007
- ...input.renderStyle !== void 0 ? { renderStyle: input.renderStyle } : {}
4119
+ ...input.renderStyle !== void 0 ? { renderStyle: input.renderStyle } : {},
4120
+ ...input.width !== void 0 ? { width: input.width } : {}
4008
4121
  };
4009
4122
  }
4010
4123
 
@@ -5601,6 +5714,20 @@ function emitGeometricTemplate(t) {
5601
5714
  ].map(([px, py]) => `${n(px ?? 0)},${n(py ?? 0)}`).join(" ");
5602
5715
  return `<polygon points="${pts}" ${attrs} />`;
5603
5716
  }
5717
+ case "rectangle": {
5718
+ const halfW = (t.width ?? 0) / 2;
5719
+ const cos = Math.cos(t.angle);
5720
+ const sin = Math.sin(t.angle);
5721
+ const perpX = -sin * halfW;
5722
+ const perpY = cos * halfW;
5723
+ const pts = [
5724
+ [cx + perpX, cy + perpY],
5725
+ [cx + r * cos + perpX, cy + r * sin + perpY],
5726
+ [cx + r * cos - perpX, cy + r * sin - perpY],
5727
+ [cx - perpX, cy - perpY]
5728
+ ].map(([px, py]) => `${n(px ?? 0)},${n(py ?? 0)}`).join(" ");
5729
+ return `<polygon points="${pts}" ${attrs} />`;
5730
+ }
5604
5731
  }
5605
5732
  }
5606
5733
  function emitHexTemplate(t, grid) {
@@ -5623,6 +5750,18 @@ function emitHexTemplate(t, grid) {
5623
5750
  case "square":
5624
5751
  cells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
5625
5752
  break;
5753
+ case "rectangle": {
5754
+ const widthCells = (t.width ?? 0) / snapUnit;
5755
+ cells = getHexCellsInRectangle(
5756
+ center2,
5757
+ t.angle,
5758
+ radiusCells,
5759
+ widthCells,
5760
+ cellSize,
5761
+ orientation
5762
+ );
5763
+ break;
5764
+ }
5626
5765
  }
5627
5766
  let d = "";
5628
5767
  for (const cell of cells) {
@@ -8311,6 +8450,21 @@ function getOverlayLayout(el, zoom) {
8311
8450
  const rotateHandle = rotatePoint(topMid, center2, angle);
8312
8451
  return { center: center2, corners, rotateHandle, angle };
8313
8452
  }
8453
+ function templateAimKnob(el, zoom) {
8454
+ if (el.type !== "template") return null;
8455
+ if (el.templateShape !== "cone" && el.templateShape !== "line" && el.templateShape !== "rectangle")
8456
+ return null;
8457
+ const gap = ROTATE_HANDLE_OFFSET / zoom;
8458
+ const dist = el.radius + gap;
8459
+ const origin = el.position;
8460
+ return {
8461
+ origin,
8462
+ knob: {
8463
+ x: origin.x + dist * Math.cos(el.angle),
8464
+ y: origin.y + dist * Math.sin(el.angle)
8465
+ }
8466
+ };
8467
+ }
8314
8468
  function getHandlePositions(bounds) {
8315
8469
  return [
8316
8470
  ["nw", { x: bounds.x, y: bounds.y }],
@@ -8444,21 +8598,66 @@ function renderSelectionBoxes(ctx, p) {
8444
8598
  } else if (el.type === "template") {
8445
8599
  ctx.setLineDash([]);
8446
8600
  ctx.fillStyle = "#ffffff";
8447
- const hx = bounds.x + bounds.w;
8448
- const hy = bounds.y + bounds.h;
8449
- ctx.fillRect(
8450
- hx - handleWorldSize / 2,
8451
- hy - handleWorldSize / 2,
8452
- handleWorldSize,
8453
- handleWorldSize
8454
- );
8455
- ctx.strokeRect(
8456
- hx - handleWorldSize / 2,
8457
- hy - handleWorldSize / 2,
8458
- handleWorldSize,
8459
- handleWorldSize
8460
- );
8601
+ if (el.templateShape === "rectangle") {
8602
+ if (p.selectedIds.length === 1) {
8603
+ const cos = Math.cos(el.angle);
8604
+ const sin = Math.sin(el.angle);
8605
+ const halfW = (el.width ?? 0) / 2;
8606
+ const pts = [
8607
+ [el.position.x + el.radius * cos, el.position.y + el.radius * sin],
8608
+ [
8609
+ el.position.x + el.radius / 2 * cos + halfW * -sin,
8610
+ el.position.y + el.radius / 2 * sin + halfW * cos
8611
+ ]
8612
+ ];
8613
+ for (const [hx, hy] of pts) {
8614
+ ctx.fillRect(
8615
+ hx - handleWorldSize / 2,
8616
+ hy - handleWorldSize / 2,
8617
+ handleWorldSize,
8618
+ handleWorldSize
8619
+ );
8620
+ ctx.strokeRect(
8621
+ hx - handleWorldSize / 2,
8622
+ hy - handleWorldSize / 2,
8623
+ handleWorldSize,
8624
+ handleWorldSize
8625
+ );
8626
+ }
8627
+ }
8628
+ } else {
8629
+ const hx = bounds.x + bounds.w;
8630
+ const hy = bounds.y + bounds.h;
8631
+ ctx.fillRect(
8632
+ hx - handleWorldSize / 2,
8633
+ hy - handleWorldSize / 2,
8634
+ handleWorldSize,
8635
+ handleWorldSize
8636
+ );
8637
+ ctx.strokeRect(
8638
+ hx - handleWorldSize / 2,
8639
+ hy - handleWorldSize / 2,
8640
+ handleWorldSize,
8641
+ handleWorldSize
8642
+ );
8643
+ }
8461
8644
  ctx.setLineDash([4 / zoom, 4 / zoom]);
8645
+ if (p.selectedIds.length === 1 && (el.templateShape === "cone" || el.templateShape === "line" || el.templateShape === "rectangle")) {
8646
+ const aim = templateAimKnob(el, zoom);
8647
+ if (aim) {
8648
+ ctx.beginPath();
8649
+ ctx.moveTo(aim.origin.x, aim.origin.y);
8650
+ ctx.lineTo(aim.knob.x, aim.knob.y);
8651
+ ctx.stroke();
8652
+ ctx.setLineDash([]);
8653
+ ctx.fillStyle = "#ffffff";
8654
+ ctx.beginPath();
8655
+ ctx.arc(aim.knob.x, aim.knob.y, handleWorldSize / 2, 0, Math.PI * 2);
8656
+ ctx.fill();
8657
+ ctx.stroke();
8658
+ ctx.setLineDash([4 / zoom, 4 / zoom]);
8659
+ }
8660
+ }
8462
8661
  }
8463
8662
  if (p.selectedIds.length === 1 && ROTATABLE_TYPES.has(el.type)) {
8464
8663
  const stemStart = topMidpoint(layout);
@@ -8604,6 +8803,7 @@ function hitTestTemplateResizeHandle(world, ctx, selectedIds) {
8604
8803
  for (const id of selectedIds) {
8605
8804
  const el = ctx.store.getById(id);
8606
8805
  if (!el || el.type !== "template") continue;
8806
+ if (el.templateShape === "rectangle") continue;
8607
8807
  const bounds = getElementBounds(el);
8608
8808
  if (!bounds) continue;
8609
8809
  const hx = bounds.x + bounds.w;
@@ -8614,6 +8814,50 @@ function hitTestTemplateResizeHandle(world, ctx, selectedIds) {
8614
8814
  }
8615
8815
  return null;
8616
8816
  }
8817
+ function hitTestTemplateAimHandle(world, ctx, selectedIds) {
8818
+ if (selectedIds.length !== 1) return null;
8819
+ const id = selectedIds[0];
8820
+ if (!id) return null;
8821
+ const el = ctx.store.getById(id);
8822
+ if (!el || el.locked) return null;
8823
+ const knob = templateAimKnob(el, ctx.camera.zoom);
8824
+ if (!knob) return null;
8825
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / ctx.camera.zoom;
8826
+ const dx = world.x - knob.knob.x;
8827
+ const dy = world.y - knob.knob.y;
8828
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
8829
+ }
8830
+ function hitTestRectangleLengthHandle(world, ctx, selectedIds) {
8831
+ if (selectedIds.length !== 1) return null;
8832
+ const id = selectedIds[0];
8833
+ if (!id) return null;
8834
+ const el = ctx.store.getById(id);
8835
+ if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
8836
+ const zoom = ctx.camera.zoom;
8837
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
8838
+ const hx = el.position.x + el.radius * Math.cos(el.angle);
8839
+ const hy = el.position.y + el.radius * Math.sin(el.angle);
8840
+ const dx = world.x - hx;
8841
+ const dy = world.y - hy;
8842
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
8843
+ }
8844
+ function hitTestRectangleWidthHandle(world, ctx, selectedIds) {
8845
+ if (selectedIds.length !== 1) return null;
8846
+ const id = selectedIds[0];
8847
+ if (!id) return null;
8848
+ const el = ctx.store.getById(id);
8849
+ if (!el || el.locked || el.type !== "template" || el.templateShape !== "rectangle") return null;
8850
+ const zoom = ctx.camera.zoom;
8851
+ const r = (HANDLE_SIZE / 2 + HANDLE_HIT_PADDING2) / zoom;
8852
+ const cos = Math.cos(el.angle);
8853
+ const sin = Math.sin(el.angle);
8854
+ const halfW = (el.width ?? 0) / 2;
8855
+ const hx = el.position.x + el.radius / 2 * cos + halfW * -sin;
8856
+ const hy = el.position.y + el.radius / 2 * sin + halfW * cos;
8857
+ const dx = world.x - hx;
8858
+ const dy = world.y - hy;
8859
+ return dx * dx + dy * dy <= r * r ? { elementId: id } : null;
8860
+ }
8617
8861
  function findElementsInRect(marquee, ctx) {
8618
8862
  const candidates = ctx.store.queryRect(marquee);
8619
8863
  const ids = [];
@@ -8768,6 +9012,34 @@ function computeTemplateResize(el, world, opts) {
8768
9012
  }
8769
9013
  return updates;
8770
9014
  }
9015
+ function computeRectangleLengthResize(el, world, opts) {
9016
+ const cos = Math.cos(el.angle);
9017
+ const sin = Math.sin(el.angle);
9018
+ let len = (world.x - el.position.x) * cos + (world.y - el.position.y) * sin;
9019
+ if (opts.snapToGrid && opts.gridSize && opts.gridSize > 0) {
9020
+ const snapUnit = opts.gridType === "hex" ? Math.sqrt(3) * opts.gridSize : opts.gridSize;
9021
+ len = Math.max(snapUnit, Math.round(len / snapUnit) * snapUnit);
9022
+ }
9023
+ len = Math.max(MIN_ELEMENT_SIZE, len);
9024
+ const updates = { radius: len };
9025
+ if (el.feetPerCell != null && opts.gridSize && opts.gridSize > 0) {
9026
+ const snapUnit = opts.gridType === "hex" ? Math.sqrt(3) * opts.gridSize : opts.gridSize;
9027
+ updates.radiusFeet = len / snapUnit * el.feetPerCell;
9028
+ }
9029
+ return updates;
9030
+ }
9031
+ function computeRectangleWidthResize(el, world, opts) {
9032
+ const cos = Math.cos(el.angle);
9033
+ const sin = Math.sin(el.angle);
9034
+ const perp = Math.abs(-(world.x - el.position.x) * sin + (world.y - el.position.y) * cos);
9035
+ let width = perp * 2;
9036
+ if (opts.snapToGrid && opts.gridSize && opts.gridSize > 0) {
9037
+ const snapUnit = opts.gridType === "hex" ? Math.sqrt(3) * opts.gridSize : opts.gridSize;
9038
+ width = Math.max(snapUnit, Math.round(width / snapUnit) * snapUnit);
9039
+ }
9040
+ width = Math.max(MIN_ELEMENT_SIZE, width);
9041
+ return { width };
9042
+ }
8771
9043
 
8772
9044
  // src/tools/select-tool.ts
8773
9045
  var SNAP_PX = 6;
@@ -8863,6 +9135,24 @@ var SelectTool = class {
8863
9135
  ctx.requestRender();
8864
9136
  return;
8865
9137
  }
9138
+ const rectLengthHit = hitTestRectangleLengthHandle(world, ctx, this._selectedIds);
9139
+ if (rectLengthHit) {
9140
+ this.mode = { type: "resizing-rect-length", elementId: rectLengthHit.elementId };
9141
+ ctx.requestRender();
9142
+ return;
9143
+ }
9144
+ const rectWidthHit = hitTestRectangleWidthHandle(world, ctx, this._selectedIds);
9145
+ if (rectWidthHit) {
9146
+ this.mode = { type: "resizing-rect-width", elementId: rectWidthHit.elementId };
9147
+ ctx.requestRender();
9148
+ return;
9149
+ }
9150
+ const aimHit = hitTestTemplateAimHandle(world, ctx, this._selectedIds);
9151
+ if (aimHit) {
9152
+ this.mode = { type: "aiming-template", elementId: aimHit.elementId };
9153
+ ctx.requestRender();
9154
+ return;
9155
+ }
8866
9156
  const rotateHit = hitTestRotateHandle(world, ctx, this._selectedIds);
8867
9157
  if (rotateHit) {
8868
9158
  const el = ctx.store.getById(rotateHit.elementId);
@@ -8944,6 +9234,32 @@ var SelectTool = class {
8944
9234
  this.handleTemplateResize(world, ctx);
8945
9235
  return;
8946
9236
  }
9237
+ if (this.mode.type === "aiming-template") {
9238
+ const el = ctx.store.getById(this.mode.elementId);
9239
+ if (el && el.type === "template" && !el.locked) {
9240
+ let a = Math.atan2(world.y - el.position.y, world.x - el.position.x);
9241
+ if (state.shiftKey) {
9242
+ const snap = ctx.gridType === "hex" ? Math.PI / 3 : ROTATE_SNAP;
9243
+ a = Math.round(a / snap) * snap;
9244
+ }
9245
+ ctx.store.update(this.mode.elementId, { angle: normalizeAngle(a) });
9246
+ ctx.requestRender();
9247
+ }
9248
+ return;
9249
+ }
9250
+ if (this.mode.type === "resizing-rect-length" || this.mode.type === "resizing-rect-width") {
9251
+ ctx.setCursor?.(this.mode.type === "resizing-rect-length" ? "ew-resize" : "ns-resize");
9252
+ const el = ctx.store.getById(this.mode.elementId);
9253
+ if (el && el.type === "template" && !el.locked) {
9254
+ const opts = { snapToGrid: ctx.snapToGrid, gridSize: ctx.gridSize, gridType: ctx.gridType };
9255
+ const patch = this.mode.type === "resizing-rect-length" ? computeRectangleLengthResize(el, world, opts) : computeRectangleWidthResize(el, world, opts);
9256
+ if (patch) {
9257
+ ctx.store.update(this.mode.elementId, patch);
9258
+ ctx.requestRender();
9259
+ }
9260
+ }
9261
+ return;
9262
+ }
8947
9263
  if (this.mode.type === "rotating") {
8948
9264
  const { elementId, center: center2, startPointerAngle, startRotation } = this.mode;
8949
9265
  const a = Math.atan2(world.y - center2.y, world.x - center2.x);
@@ -9150,6 +9466,18 @@ var SelectTool = class {
9150
9466
  ctx.setCursor?.("nwse-resize");
9151
9467
  return null;
9152
9468
  }
9469
+ if (hitTestRectangleLengthHandle(world, ctx, this._selectedIds)) {
9470
+ ctx.setCursor?.("ew-resize");
9471
+ return null;
9472
+ }
9473
+ if (hitTestRectangleWidthHandle(world, ctx, this._selectedIds)) {
9474
+ ctx.setCursor?.("ns-resize");
9475
+ return null;
9476
+ }
9477
+ if (hitTestTemplateAimHandle(world, ctx, this._selectedIds)) {
9478
+ ctx.setCursor?.("grab");
9479
+ return null;
9480
+ }
9153
9481
  if (hitTestRotateHandle(world, ctx, this._selectedIds)) {
9154
9482
  ctx.setCursor?.("grab");
9155
9483
  return null;
@@ -9810,6 +10138,11 @@ var MeasureTool = class {
9810
10138
  };
9811
10139
 
9812
10140
  // src/tools/template-tool.ts
10141
+ var MIN_RECT_WIDTH = 20;
10142
+ function defaultRectWidth(radius, scaleUnit) {
10143
+ if (scaleUnit > 0) return scaleUnit;
10144
+ return Math.max(MIN_RECT_WIDTH, radius * 0.25);
10145
+ }
9813
10146
  var TemplateTool = class {
9814
10147
  name = "template";
9815
10148
  drawing = false;
@@ -9819,6 +10152,7 @@ var TemplateTool = class {
9819
10152
  gridType;
9820
10153
  hexOrientation;
9821
10154
  snapEnabled = false;
10155
+ feetScaleUnit = 0;
9822
10156
  templateShape;
9823
10157
  fillColor;
9824
10158
  strokeColor;
@@ -9867,6 +10201,7 @@ var TemplateTool = class {
9867
10201
  this.gridType = ctx.gridType;
9868
10202
  this.hexOrientation = ctx.hexOrientation;
9869
10203
  this.snapEnabled = !!ctx.gridType || (ctx.snapToGrid ?? false);
10204
+ this.feetScaleUnit = ctx.gridSize && ctx.gridSize > 0 ? ctx.gridType === "hex" ? Math.sqrt(3) * ctx.gridSize : ctx.gridSize : 0;
9870
10205
  const world = ctx.camera.screenToWorld({ x: state.x, y: state.y });
9871
10206
  this.origin = this.snapToGrid(world, ctx);
9872
10207
  this.current = { ...this.origin };
@@ -9886,11 +10221,13 @@ var TemplateTool = class {
9886
10221
  const snapUnit = gridSize && gridSize > 0 ? ctx.gridType === "hex" ? Math.sqrt(3) * gridSize : gridSize : 0;
9887
10222
  const cells = snapUnit > 0 ? radius / snapUnit : 0;
9888
10223
  const radiusFeet = cells * this.feetPerCell;
10224
+ const width = this.templateShape === "rectangle" ? defaultRectWidth(radius, this.feetScaleUnit) : void 0;
9889
10225
  const element = createTemplate({
9890
10226
  position: { ...this.origin },
9891
10227
  templateShape: this.templateShape,
9892
10228
  radius,
9893
10229
  angle,
10230
+ width,
9894
10231
  fillColor: this.fillColor,
9895
10232
  strokeColor: this.strokeColor,
9896
10233
  strokeWidth: this.strokeWidth,
@@ -9965,7 +10302,24 @@ var TemplateTool = class {
9965
10302
  ctx.stroke();
9966
10303
  break;
9967
10304
  }
10305
+ case "rectangle": {
10306
+ const halfW = defaultRectWidth(radius, this.feetScaleUnit) / 2;
10307
+ const cos = Math.cos(angle);
10308
+ const sin = Math.sin(angle);
10309
+ const perpX = -sin * halfW;
10310
+ const perpY = cos * halfW;
10311
+ ctx.beginPath();
10312
+ ctx.moveTo(cx + perpX, cy + perpY);
10313
+ ctx.lineTo(cx + radius * cos + perpX, cy + radius * sin + perpY);
10314
+ ctx.lineTo(cx + radius * cos - perpX, cy + radius * sin - perpY);
10315
+ ctx.lineTo(cx - perpX, cy - perpY);
10316
+ ctx.closePath();
10317
+ ctx.fill();
10318
+ ctx.stroke();
10319
+ break;
10320
+ }
9968
10321
  }
10322
+ this.drawFeetLabel(ctx, radius);
9969
10323
  ctx.restore();
9970
10324
  }
9971
10325
  renderHexOverlay(ctx, radius) {
@@ -9990,6 +10344,18 @@ var TemplateTool = class {
9990
10344
  case "square":
9991
10345
  hexCells = getHexCellsInSquare(center2, radiusCells, cellSize, orientation);
9992
10346
  break;
10347
+ case "rectangle": {
10348
+ const widthCells = defaultRectWidth(radius, this.feetScaleUnit) / snapUnit;
10349
+ hexCells = getHexCellsInRectangle(
10350
+ center2,
10351
+ angle,
10352
+ radiusCells,
10353
+ widthCells,
10354
+ cellSize,
10355
+ orientation
10356
+ );
10357
+ break;
10358
+ }
9993
10359
  }
9994
10360
  ctx.save();
9995
10361
  ctx.globalAlpha = 0.4;
@@ -10006,7 +10372,7 @@ var TemplateTool = class {
10006
10372
  ctx.strokeStyle = this.strokeColor;
10007
10373
  ctx.lineWidth = this.strokeWidth;
10008
10374
  ctx.stroke();
10009
- if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square") {
10375
+ if (this.templateShape === "cone" || this.templateShape === "line" || this.templateShape === "circle" || this.templateShape === "square" || this.templateShape === "rectangle") {
10010
10376
  ctx.globalAlpha = 0.5;
10011
10377
  ctx.beginPath();
10012
10378
  drawHexPath(ctx, center2.x, center2.y, cellSize, orientation);
@@ -10016,30 +10382,7 @@ var TemplateTool = class {
10016
10382
  ctx.lineWidth = this.strokeWidth;
10017
10383
  ctx.stroke();
10018
10384
  }
10019
- if (this.templateShape === "circle") {
10020
- const feet = radiusCells * this.feetPerCell;
10021
- if (feet > 0) {
10022
- ctx.globalAlpha = 1;
10023
- const label = `${Math.round(feet)} ft`;
10024
- const fontSize = Math.max(10, Math.min(14, radius * 0.15));
10025
- ctx.font = `bold ${fontSize}px system-ui, sans-serif`;
10026
- ctx.textAlign = "center";
10027
- ctx.textBaseline = "bottom";
10028
- const textX = center2.x;
10029
- const textY = center2.y - 4;
10030
- const metrics = ctx.measureText(label);
10031
- const padX = 4;
10032
- const padY = 2;
10033
- const textW = metrics.width + padX * 2;
10034
- const textH = fontSize + padY * 2;
10035
- ctx.fillStyle = "rgba(255, 255, 255, 0.85)";
10036
- ctx.beginPath();
10037
- ctx.roundRect(textX - textW / 2, textY - textH, textW, textH, 3);
10038
- ctx.fill();
10039
- ctx.fillStyle = this.strokeColor;
10040
- ctx.fillText(label, textX, textY - padY);
10041
- }
10042
- }
10385
+ this.drawFeetLabel(ctx, radius);
10043
10386
  ctx.restore();
10044
10387
  }
10045
10388
  computeRadius() {
@@ -10057,6 +10400,17 @@ var TemplateTool = class {
10057
10400
  const dy = this.current.y - this.origin.y;
10058
10401
  return Math.atan2(dy, dx);
10059
10402
  }
10403
+ drawFeetLabel(ctx, radius) {
10404
+ const feet = this.feetScaleUnit > 0 ? radius / this.feetScaleUnit * this.feetPerCell : 0;
10405
+ renderTemplateFeetLabel(ctx, {
10406
+ position: this.origin,
10407
+ radius,
10408
+ angle: this.computeAngle(),
10409
+ templateShape: this.templateShape,
10410
+ feet,
10411
+ color: this.strokeColor
10412
+ });
10413
+ }
10060
10414
  snapToGrid(point, ctx) {
10061
10415
  if (!ctx.gridSize) return point;
10062
10416
  if (ctx.gridType === "hex" && ctx.hexOrientation) {
@@ -10186,7 +10540,7 @@ var LaserTool = class {
10186
10540
  };
10187
10541
 
10188
10542
  // src/index.ts
10189
- var VERSION = "0.46.1";
10543
+ var VERSION = "0.48.0";
10190
10544
  // Annotate the CommonJS export names for ESM import in node:
10191
10545
  0 && (module.exports = {
10192
10546
  ArrowTool,
@@ -10238,6 +10592,7 @@ var VERSION = "0.46.1";
10238
10592
  getHexCellsInCone,
10239
10593
  getHexCellsInLine,
10240
10594
  getHexCellsInRadius,
10595
+ getHexCellsInRectangle,
10241
10596
  getHexCellsInSquare,
10242
10597
  getHexDistance,
10243
10598
  isNearBezier,