merge: combine all features from both codebases - mobile dashboard + layer panel + notifications + shares + all fixes
This commit is contained in:
@@ -18,6 +18,12 @@ export interface RenderOptions {
|
||||
backgroundOffsetY: number;
|
||||
backgroundRotation: number;
|
||||
backgroundOpacity: number;
|
||||
unit?: 'mm' | 'cm' | 'm';
|
||||
scaleFactor?: number;
|
||||
showGridLabels?: boolean;
|
||||
canvasBgColor?: string;
|
||||
gridColor?: string;
|
||||
rulerEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SelectionState {
|
||||
@@ -30,7 +36,7 @@ export interface SelectionState {
|
||||
export interface SnapPoint {
|
||||
x: number;
|
||||
y: number;
|
||||
type: 'endpoint' | 'midpoint' | 'center' | 'intersection' | 'nearest';
|
||||
type: 'endpoint' | 'midpoint' | 'center' | 'intersection' | 'nearest' | 'grid' | 'perpendicular' | 'tangent' | 'quadrant' | 'none';
|
||||
}
|
||||
|
||||
export class RenderEngine {
|
||||
@@ -70,6 +76,9 @@ export class RenderEngine {
|
||||
backgroundOffsetY: 0,
|
||||
backgroundRotation: 0,
|
||||
backgroundOpacity: 0.5,
|
||||
canvasBgColor: '#1e1e2e',
|
||||
gridColor: '#3a3a4a',
|
||||
rulerEnabled: true,
|
||||
};
|
||||
this.selection = {
|
||||
selectedIds: new Set(),
|
||||
@@ -132,16 +141,22 @@ export class RenderEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private groups: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }> = [];
|
||||
|
||||
setGroups(groups: Array<{ id: string; name: string; elementIds: string[]; parentGroupId: string | null }>): void {
|
||||
this.groups = groups;
|
||||
}
|
||||
|
||||
render(): void {
|
||||
const w = this.canvas.width / this.dpr;
|
||||
const h = this.canvas.height / this.dpr;
|
||||
this.ctx.save();
|
||||
this.ctx.fillStyle = '#1e1e2e';
|
||||
this.ctx.fillStyle = this.options.canvasBgColor || '#1e1e2e';
|
||||
this.ctx.fillRect(0, 0, w, h);
|
||||
|
||||
if (this.options.showGrid) this.drawGrid(w, h);
|
||||
if (this.options.backgroundSrc) this.drawBackground();
|
||||
|
||||
this.drawRuler(w, h);
|
||||
const viewport = this.zoomPan.getViewport();
|
||||
const visibleElements = this.spatialIndex.search({
|
||||
minX: viewport.minX, minY: viewport.minY,
|
||||
@@ -168,6 +183,7 @@ export class RenderEngine {
|
||||
}
|
||||
|
||||
this.drawSelectionBox();
|
||||
this.drawGroupBoxes();
|
||||
if (this.options.showSnapPoints) this.drawSnapPoints();
|
||||
if (this.options.showOrtho) this.drawOrtho();
|
||||
this.ctx.restore();
|
||||
@@ -180,7 +196,7 @@ export class RenderEngine {
|
||||
const offsetX = this.zoomPan.getTransform().e % gridSize;
|
||||
const offsetY = this.zoomPan.getTransform().f % gridSize;
|
||||
|
||||
this.ctx.strokeStyle = '#2a2a3e';
|
||||
this.ctx.strokeStyle = this.options.gridColor || '#3a3a4a';
|
||||
this.ctx.lineWidth = 1;
|
||||
this.ctx.beginPath();
|
||||
for (let x = offsetX; x < w; x += gridSize) {
|
||||
@@ -198,7 +214,8 @@ export class RenderEngine {
|
||||
if (majorSize >= 20) {
|
||||
const majOffX = this.zoomPan.getTransform().e % majorSize;
|
||||
const majOffY = this.zoomPan.getTransform().f % majorSize;
|
||||
this.ctx.strokeStyle = '#33334a';
|
||||
// Major lines use a brighter variant of gridColor
|
||||
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 15);
|
||||
this.ctx.beginPath();
|
||||
for (let x = majOffX; x < w; x += majorSize) {
|
||||
this.ctx.moveTo(x, 0);
|
||||
@@ -209,9 +226,158 @@ export class RenderEngine {
|
||||
this.ctx.lineTo(w, y);
|
||||
}
|
||||
this.ctx.stroke();
|
||||
|
||||
// Draw axis labels with unit at each major grid line
|
||||
if (this.options.showGridLabels !== false) {
|
||||
const unit = this.options.unit || 'mm';
|
||||
const scaleFactor = this.options.scaleFactor || 1;
|
||||
const transform = this.zoomPan.getTransform();
|
||||
const scale = this.zoomPan.getScale();
|
||||
const worldGridSize = this.options.gridSize * 5; // world units per major line
|
||||
|
||||
this.ctx.fillStyle = '#666680';
|
||||
this.ctx.font = '10px sans-serif';
|
||||
this.ctx.textBaseline = 'top';
|
||||
|
||||
// X-axis labels (top edge)
|
||||
const xStartWorld = Math.floor((-transform.e / scale) / worldGridSize) * worldGridSize;
|
||||
for (let i = 0; i * majorSize + majOffX < w; i++) {
|
||||
const screenX = majOffX + i * majorSize;
|
||||
const worldX = xStartWorld + i * worldGridSize;
|
||||
if (screenX < 30) continue; // skip too-close-to-edge labels
|
||||
const label = this.formatGridLabel(worldX, unit, scaleFactor);
|
||||
this.ctx.fillText(label, screenX + 2, 2);
|
||||
}
|
||||
|
||||
// Y-axis labels (left edge)
|
||||
this.ctx.textAlign = 'left';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
const yStartWorld = Math.floor((-transform.f / scale) / worldGridSize) * worldGridSize;
|
||||
for (let i = 0; i * majorSize + majOffY < h; i++) {
|
||||
const screenY = majOffY + i * majorSize;
|
||||
const worldY = yStartWorld + i * worldGridSize;
|
||||
if (screenY < 15) continue;
|
||||
const label = this.formatGridLabel(worldY, unit, scaleFactor);
|
||||
this.ctx.fillText(label, 2, screenY - 6);
|
||||
}
|
||||
|
||||
this.ctx.textAlign = 'start';
|
||||
this.ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private formatGridLabel(worldVal: number, unit: 'mm' | 'cm' | 'm', scaleFactor: number): string {
|
||||
const mm = worldVal * scaleFactor;
|
||||
switch (unit) {
|
||||
case 'mm':
|
||||
return `${mm.toFixed(0)}mm`;
|
||||
case 'cm':
|
||||
return `${(mm / 10).toFixed(0)}cm`;
|
||||
case 'm':
|
||||
return `${(mm / 1000).toFixed(2)}m`;
|
||||
}
|
||||
}
|
||||
|
||||
private lightenColor(hex: string, percent: number): string {
|
||||
const num = parseInt(hex.replace('#', ''), 16);
|
||||
if (isNaN(num)) return hex;
|
||||
const r = Math.min(255, (num >> 16) + Math.round(255 * percent / 100));
|
||||
const g = Math.min(255, ((num >> 8) & 0x00FF) + Math.round(255 * percent / 100));
|
||||
const b = Math.min(255, (num & 0x0000FF) + Math.round(255 * percent / 100));
|
||||
return `#${((r << 16) | (g << 8) | b).toString(16).padStart(6, '0')}`;
|
||||
}
|
||||
|
||||
private drawRuler(w: number, h: number): void {
|
||||
if (!this.options.rulerEnabled) return;
|
||||
const scale = this.zoomPan.getScale();
|
||||
const transform = this.zoomPan.getTransform();
|
||||
const unit = this.options.unit || 'mm';
|
||||
const scaleFactor = this.options.scaleFactor || 1;
|
||||
const rulerSize = 20;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.fillStyle = this.options.canvasBgColor || '#1e1e2e';
|
||||
this.ctx.fillRect(0, 0, w, rulerSize);
|
||||
this.ctx.fillRect(0, 0, rulerSize, h);
|
||||
|
||||
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
|
||||
this.ctx.lineWidth = 1;
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(0, rulerSize);
|
||||
this.ctx.lineTo(w, rulerSize);
|
||||
this.ctx.moveTo(rulerSize, 0);
|
||||
this.ctx.lineTo(rulerSize, h);
|
||||
this.ctx.stroke();
|
||||
|
||||
// Determine tick spacing in world units (aim for ~50px between major ticks)
|
||||
const targetPx = 50;
|
||||
const worldPerPx = 1 / scale;
|
||||
const rawTickSpacing = targetPx * worldPerPx;
|
||||
const niceSpacings = [1, 2, 5, 10, 20, 50, 100, 200, 500, 1000, 2000, 5000, 10000];
|
||||
let tickSpacing = niceSpacings[0];
|
||||
for (const ns of niceSpacings) {
|
||||
if (ns >= rawTickSpacing) { tickSpacing = ns; break; }
|
||||
tickSpacing = ns;
|
||||
}
|
||||
const tickPx = tickSpacing * scale;
|
||||
|
||||
this.ctx.fillStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 50);
|
||||
this.ctx.font = '9px sans-serif';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
|
||||
// X-axis ruler (top)
|
||||
this.ctx.textAlign = 'center';
|
||||
const xStartWorld = Math.floor((-transform.e / scale) / tickSpacing) * tickSpacing;
|
||||
for (let i = 0; ; i++) {
|
||||
const screenX = transform.e + (xStartWorld + i * tickSpacing) * scale;
|
||||
if (screenX < rulerSize) continue;
|
||||
if (screenX > w) break;
|
||||
// Tick mark
|
||||
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(screenX, rulerSize - 6);
|
||||
this.ctx.lineTo(screenX, rulerSize);
|
||||
this.ctx.stroke();
|
||||
// Label
|
||||
const worldVal = (xStartWorld + i * tickSpacing) * scaleFactor;
|
||||
let label: string;
|
||||
switch (unit) {
|
||||
case 'mm': label = `${worldVal.toFixed(0)}`; break;
|
||||
case 'cm': label = `${(worldVal / 10).toFixed(0)}`; break;
|
||||
case 'm': label = `${(worldVal / 1000).toFixed(2)}`; break;
|
||||
}
|
||||
this.ctx.fillText(label, screenX, rulerSize / 2);
|
||||
}
|
||||
|
||||
// Y-axis ruler (left)
|
||||
this.ctx.textAlign = 'right';
|
||||
this.ctx.textBaseline = 'middle';
|
||||
const yStartWorld = Math.floor((-transform.f / scale) / tickSpacing) * tickSpacing;
|
||||
for (let i = 0; ; i++) {
|
||||
const screenY = transform.f + (yStartWorld + i * tickSpacing) * scale;
|
||||
if (screenY < rulerSize) continue;
|
||||
if (screenY > h) break;
|
||||
this.ctx.strokeStyle = this.lightenColor(this.options.gridColor || '#3a3a4a', 30);
|
||||
this.ctx.beginPath();
|
||||
this.ctx.moveTo(rulerSize - 6, screenY);
|
||||
this.ctx.lineTo(rulerSize, screenY);
|
||||
this.ctx.stroke();
|
||||
const worldVal = (yStartWorld + i * tickSpacing) * scaleFactor;
|
||||
let label: string;
|
||||
switch (unit) {
|
||||
case 'mm': label = `${worldVal.toFixed(0)}`; break;
|
||||
case 'cm': label = `${(worldVal / 10).toFixed(0)}`; break;
|
||||
case 'm': label = `${(worldVal / 1000).toFixed(2)}`; break;
|
||||
}
|
||||
this.ctx.fillText(label, rulerSize - 2, screenY);
|
||||
}
|
||||
|
||||
this.ctx.restore();
|
||||
this.ctx.textAlign = 'start';
|
||||
this.ctx.textBaseline = 'alphabetic';
|
||||
}
|
||||
|
||||
private drawBackground(): void {
|
||||
// Background image rendering with transform
|
||||
const img = new Image();
|
||||
@@ -825,6 +991,51 @@ export class RenderEngine {
|
||||
}
|
||||
}
|
||||
|
||||
private drawGroupBoxes(): void {
|
||||
if (this.groups.length === 0) return;
|
||||
const s = this.zoomPan.getScale();
|
||||
const ox = this.zoomPan.getTransform().e;
|
||||
const oy = this.zoomPan.getTransform().f;
|
||||
const visibleElements = this.spatialIndex.search(this.zoomPan.getViewport());
|
||||
const elementMap = new Map(visibleElements.map(e => [e.id, e]));
|
||||
|
||||
for (const group of this.groups) {
|
||||
const els = group.elementIds.map(id => elementMap.get(id)).filter((e): e is CADElement => e !== undefined);
|
||||
if (els.length === 0) continue;
|
||||
|
||||
let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity;
|
||||
for (const el of els) {
|
||||
const bb = this.getElementBBox(el);
|
||||
minX = Math.min(minX, bb.minX);
|
||||
minY = Math.min(minY, bb.minY);
|
||||
maxX = Math.max(maxX, bb.maxX);
|
||||
maxY = Math.max(maxY, bb.maxY);
|
||||
}
|
||||
if (minX === Infinity) continue;
|
||||
|
||||
const sx1 = minX * s + ox;
|
||||
const sy1 = minY * s + oy;
|
||||
const sx2 = maxX * s + ox;
|
||||
const sy2 = maxY * s + oy;
|
||||
const pad = 4;
|
||||
|
||||
this.ctx.save();
|
||||
this.ctx.strokeStyle = '#ff9800';
|
||||
this.ctx.lineWidth = 1.5;
|
||||
this.ctx.setLineDash([6, 4]);
|
||||
this.ctx.strokeRect(sx1 - pad, sy1 - pad, (sx2 - sx1) + pad * 2, (sy2 - sy1) + pad * 2);
|
||||
this.ctx.setLineDash([]);
|
||||
|
||||
// Group name label
|
||||
this.ctx.font = '11px sans-serif';
|
||||
this.ctx.fillStyle = '#ff9800';
|
||||
this.ctx.textAlign = 'left';
|
||||
this.ctx.textBaseline = 'top';
|
||||
this.ctx.fillText(group.name, sx1 - pad + 2, sy1 - pad - 14);
|
||||
this.ctx.restore();
|
||||
}
|
||||
}
|
||||
|
||||
private drawSelectionBox(): void {
|
||||
if (!this.selection.boxStart || !this.selection.boxEnd) return;
|
||||
const s = this.zoomPan.getScale();
|
||||
|
||||
Reference in New Issue
Block a user