OXYGENHUB
◆ main openmsx / src / Plotter.cc
24.0 KiB · C++ · last changed chore(): initial commit 1 month ago source blame history raw
1
#include "Plotter.hh"
2
3
#include "FileOperations.hh"
4
#include "IntegerSetting.hh"
5
#include "MSXCliComm.hh"
6
#include "MSXMotherBoard.hh"
7
#include "PNG.hh"
8
#include "PlotterFont.hh"
9
10
#include "ScopedAssign.hh"
11
#include "gl_mat.hh"
12
#include "ranges.hh"
13
#include "serialize.hh"
14
#include "strCat.hh"
15
16
#include <algorithm>
17
#include <cmath>
18
19
namespace openmsx {
20
21
// Enable debug output by setting to 0
22
#if 1
23
template<typename... Args>
24
static void printDebug(Args&&...)
25
{
26
	// nothing
27
}
28
#else
29
template<typename... Args>
30
static void printDebug(Args&&... args)
31
{
32
	std::cerr << strCat(std::forward<Args>(args)...) << '\n';
33
}
34
#endif
35
36
MSXPlotter::MSXPlotter(MSXMotherBoard& motherBoard)
37
	: cliComm(motherBoard.getMSXCliComm())
38
	, dpiSetting(motherBoard.getSharedStuff<IntegerSetting>(
39
		"print-resolution",
40
		motherBoard.getCommandController(), "print-resolution",
41
		"resolution of the output image of emulated dot matrix printer in DPI",
42
		300, 72, 1200))
43
	, charSetSetting(
44
		motherBoard.getCommandController(),
45
		"plotter-charset",
46
		"character set for the MSX plotter",
47
		CharacterSet::International,
48
		EnumSetting<CharacterSet>::Map{
49
			{"international", CharacterSet::International},
50
			{"din",           CharacterSet::DIN}})
51
	, dipSwitch4Setting(
52
		motherBoard.getCommandController(),
53
		"plotter-dipswitch4",
54
		"dipswitch 4 setting for the MSX plotter",
55
		false)
56
	, penThicknessSetting(
57
		motherBoard.getCommandController(),
58
		"plotter-pen-thickness",
59
		"pen thickness for the MSX plotter",
60
		PenThickness::Standard,
61
		EnumSetting<PenThickness>::Map{
62
			{"standard", PenThickness::Standard},
63
			{"thick",    PenThickness::Thick}})
64
{
65
	resetSettings();
66
	ensurePrintPage();
67
}
68
69
MSXPlotter::~MSXPlotter()
70
{
71
	flushEmulatedPrinter();
72
}
73
74
zstring_view MSXPlotter::getName() const
75
{
76
	return "MSXPlotter";
77
}
78
79
void MSXPlotter::plugHelper(Connector& connector_, EmuTime time)
80
{
81
	PrinterCore::plugHelper(connector_, time);
82
	resetSettings();
83
	ensurePrintPage();
84
}
85
86
zstring_view MSXPlotter::getDescription() const
87
{
88
	return "Sony PRN-C41 Color Plotter (4 pens)";
89
}
90
91
MSXPlotter::CharacterSet MSXPlotter::getCharacterSet() const
92
{
93
	return charSetSetting.getEnum();
94
}
95
96
void MSXPlotter::setCharacterSet(CharacterSet cs)
97
{
98
	charSetSetting.setEnum(cs);
99
}
100
101
bool MSXPlotter::getDipSwitch4() const
102
{
103
	return dipSwitch4Setting.getBoolean();
104
}
105
106
void MSXPlotter::setDipSwitch4(bool enabled)
107
{
108
	dipSwitch4Setting.setBoolean(enabled);
109
}
110
111
void MSXPlotter::cyclePen()
112
{
113
	selectedPen = (selectedPen + 1) % 4;
114
}
115
116
void MSXPlotter::moveStep(gl::vec2 delta)
117
{
118
	gl::vec2 oldAbsPos = penPosition + origin;
119
	gl::vec2 newAbsPos = gl::clamp(oldAbsPos + delta, gl::vec2{0.0f}, PLOT_AREA);
120
	penPosition = newAbsPos - origin;
121
}
122
123
void MSXPlotter::ejectPaper()
124
{
125
	flushEmulatedPrinter();
126
	resetSettings(); // Resets pen pos and other states
127
	ensurePrintPage();
128
}
129
130
void MSXPlotter::write(uint8_t data)
131
{
132
	printDebug("Plotter: received 0x", hex_string<2>(data),
133
	           " mode=", (mode == Mode::TEXT ? "TEXT" : "GRAPHIC"),
134
	           " escState=", int(escState));
135
136
	// Handle ESC sequence state first
137
	switch (escState) {
138
	using enum EscState;
139
	case ESC: // Got ESC, waiting for next char
140
		if (data == '#') {
141
			// ESC # = enter graphic mode
142
			printDebug("Plotter: entering GRAPHIC mode");
143
			mode = Mode::GRAPHIC;
144
			graphicCmdBuffer.clear();
145
			escState = NONE;
146
		} else if (data == '$') {
147
			// ESC $ = return to text mode, execute any pending graphics
148
			printDebug("Plotter: returning to TEXT mode");
149
			if (!graphicCmdBuffer.empty()) {
150
				executeGraphicCommand();
151
				graphicCmdBuffer.clear();
152
			}
153
			liftPen(); // end any open path on mode change
154
			mode = Mode::TEXT;
155
			escState = NONE;
156
		} else if (data == 'C') {
157
			// ESC C = color set, wait for color digit
158
			escState = ESC_C;
159
		} else {
160
			// Unknown ESC sequence, ignore
161
			cliComm.printWarning(
162
				"Plotter: unknown ESC sequence 0x", hex_string<2>(data));
163
			escState = NONE;
164
		}
165
		break;
166
167
	case ESC_C: // Got ESC C, waiting for color digit '0'-'3'
168
		if ('0' <= data && data <= '3') {
169
			liftPen();
170
			selectedPen = data - '0';
171
			printDebug("Plotter: selected pen ", selectedPen);
172
			terminatorSkip = TerminatorSkip::START;
173
		}
174
		escState = NONE;
175
		break;
176
177
	case EscState::ESC_S: // Got DC2 (0x12)
178
		if (data <= 15) {
179
			// Raw byte 0-15
180
			charScale = data;
181
			updateLineFeed();
182
			printDebug("Plotter: text scale set to ", charScale);
183
			terminatorSkip = TerminatorSkip::START;
184
			escState = NONE;
185
		} else if ('0' <= data && data <= '9') {
186
			// ASCII digit
187
			unsigned val = data - '0';
188
			if (val == 0 || val >= 2) {
189
				// '0' or '2'-'9' -> single digit, final
190
				charScale = val;
191
				updateLineFeed();
192
				printDebug("Plotter: text scale set to ", charScale);
193
				terminatorSkip = TerminatorSkip::START;
194
				escState = NONE;
195
			} else {
196
				// '1' -> could be start of "0"-"15"
197
				pendingScaleDigit = val;
198
				escState = ESC_S_EXP_DIGIT;
199
			}
200
		} else {
201
			// Invalid scale parameter, ignore command but process char
202
			escState = NONE;
203
			processTextMode(data);
204
		}
205
		break;
206
207
	case ESC_S_EXP_DIGIT: // Got DC2 + '1', waiting for potential second digit
208
		if ('0' <= data && data <= '9') {
209
			unsigned val = pendingScaleDigit * 10 + (data - '0');
210
			if (val <= 15) {
211
				charScale = val;
212
				updateLineFeed();
213
				printDebug("Plotter: text scale set to ", charScale);
214
				terminatorSkip = TerminatorSkip::START;
215
				escState = NONE;
216
			} else {
217
				// > 15, valid first digit but invalid second.
218
				// e.g. "19". Interpret first digit as scale, second as text.
219
				// Or ignore? for now ignore
220
				escState = NONE;
221
				processTextMode(data);
222
			}
223
		} else {
224
			// Not a digit. e.g. "1A".
225
			// Execute the pending '1' as scale.
226
			charScale = pendingScaleDigit;
227
			updateLineFeed();
228
			printDebug("Plotter: text scale set to ", charScale);
229
			terminatorSkip = TerminatorSkip::START;
230
				// Should we skip terminators here too if the first digit was
231
				// valid? The command technically executed properly with the
232
				// single digit. But followed by 'A', user probably didn't
233
				// intend a terminator sequence immediately. However, strictly
234
				// speaking, if '1' sets scale, command is done.
235
			escState = NONE;
236
			processTextMode(data);
237
		}
238
		break;
239
240
	case NONE:
241
		// Route to appropriate mode handler
242
		if (mode == Mode::TEXT) {
243
			processTextMode(data);
244
		} else {
245
			processGraphicMode(data);
246
		}
247
		break;
248
	}
249
}
250
251
void MSXPlotter::forceFormFeed()
252
{
253
	flushEmulatedPrinter();
254
}
255
256
void MSXPlotter::processTextMode(uint8_t data)
257
{
258
	using enum TerminatorSkip;
259
	if (terminatorSkip != NONE) {
260
		if (data == 0x0D) { // CR
261
			terminatorSkip = SEEN_CR;
262
			return;
263
		} else if (data == 0x0A) { // LF
264
			terminatorSkip = NONE;
265
			return;
266
		} else {
267
			terminatorSkip = NONE;
268
			// continue to process data
269
		}
270
	}
271
272
	if (printNext) {
273
		// CHR$(1) prefix: print alternate character (0-31) by subtracting 64
274
		// e.g., CHR$(1)+CHR$(65) prints character 1 (65-64=1)
275
		drawCharacter(uint8_t(data - 64));
276
		printNext = false;
277
		return;
278
	}
279
280
	switch (data) {
281
	case 0x01: // Header / Literal prefix
282
		printNext = true;
283
		break;
284
	case 0x08: // BS - Backspace
285
		penPosition.x = std::max(penPosition.x - float(8 + 2 * charScale), 0.0f);
286
		break;
287
	case 0x0d: // CR - Carriage return, if dipswitch 4 is set, also do a line feed
288
		penPosition.x = 0.0f;
289
		if (!getDipSwitch4()) break;
290
		[[fallthrough]];
291
	case 0x0a: // LF - Line feed
292
		penPosition.y -= lineFeed;
293
		if (penPosition.y < -1354.0f) { // Page end reached
294
			flushEmulatedPrinter();
295
			ensurePrintPage();
296
			penPosition.y = 30.0f; // Reset to top of new page
297
		}
298
		break;
299
	case 0x0b: // VT - Line up (feed paper to previous line)
300
		penPosition.y = std::min(penPosition.y + lineFeed, 30.0f);
301
		break;
302
	case 0x0C: // FF - Form feed (top of form)
303
		flushEmulatedPrinter();
304
		ensurePrintPage();
305
		penPosition = {0.0f, 30.0f};
306
		break;
307
	case 0x12: // DC2 - Scale set prefix (wait for next char)
308
		escState = EscState::ESC_S;
309
		break;
310
	case 0x1b: // ESC - Start escape sequence for mode (text/graphics)
311
		escState = EscState::ESC;
312
		break;
313
	default:
314
		// Printable character in text mode (Allow all >= 32)
315
		if (data >= 32) {
316
			// Check for line wrap before drawing
317
			// Estimate width: 6 steps base * scaleFactor
318
			// Or simply check current position
319
			if (penPosition.x >= RIGHT_BORDER) {
320
				// Auto CR/LF
321
				penPosition.x = 0.0f;
322
				penPosition.y -= lineFeed;
323
				if (penPosition.y < -1354.0f) {
324
					flushEmulatedPrinter();
325
					ensurePrintPage();
326
					penPosition.y = 30.0f;
327
				}
328
			}
329
			drawCharacter(data);
330
		}
331
		break;
332
	}
333
}
334
335
void MSXPlotter::processGraphicMode(uint8_t data)
336
{
337
	// In graphic mode, accumulate command chars until delimiter
338
	// Commands are ASCII text like "J100,-100,50,-100" terminated by CR/LF or
339
	// next command
340
341
	if (data == one_of(0x0d, 0x0a)) {
342
		// CR or LF terminates command
343
		executeGraphicCommand();
344
	} else if (data == 0x1b) {
345
		// ESC in graphic mode - start escape sequence
346
		executeGraphicCommand();
347
		escState = EscState::ESC;
348
	} else if (32 <= data && data < 127) {
349
		// Accumulate command characters
350
		graphicCmdBuffer += char(data);
351
352
		// Execute immediately if buffer starts with known command and we hit a
353
		// letter (next command) Commands start with a letter, followed by numbers
354
		// and commas
355
		if (graphicCmdBuffer.size() > 1) {
356
			// Special handling for P command which takes arbitrary text
357
			bool isPrintCmd = (graphicCmdBuffer[0] == 'P');
358
359
			char lastChar = graphicCmdBuffer.back();
360
			bool isAlpha  = ('a' <= lastChar && lastChar <= 'z') ||
361
			                ('A' <= lastChar && lastChar <= 'Z');
362
			if (!isPrintCmd && isAlpha && lastChar != one_of('E', 'e', graphicCmdBuffer.front())) {
363
				// New command starting, execute previous
364
				char nextCmd = graphicCmdBuffer.back();
365
				graphicCmdBuffer.pop_back();
366
				executeGraphicCommand();
367
				graphicCmdBuffer = nextCmd;
368
			}
369
		}
370
	}
371
}
372
373
void MSXPlotter::executeGraphicCommand()
374
{
375
	if (graphicCmdBuffer.empty()) return;
376
377
	printDebug("Plotter: executing graphic command '", graphicCmdBuffer, "'");
378
379
	char cmd = graphicCmdBuffer[0];
380
	std::string args = graphicCmdBuffer.substr(1);
381
382
	// Parse comma-separated numbers (integers or floats)
383
	std::vector<float> coords;
384
	size_t pos = 0;
385
	while (pos < args.size()) {
386
		// Skip whitespace and commas
387
		while (pos < args.size() && (args[pos] == ',' || args[pos] == ' ')) {
388
			++pos;
389
		}
390
		if (pos >= args.size()) break;
391
392
		// Try to parse a number
393
		size_t nextPos;
394
		try {
395
			// Using stod is easiest for double parsing, but we need to check if it
396
			// actually parses std::stod throws if no conversion. Manually check if
397
			// valid start of number? Valid: digit, +, -, or .
398
			char c = args[pos];
399
			if (('0' <= c && c <= '9') || c == one_of('-', '+', '.')) {
400
				// Check if it's just a lonely '+' or '-' or '.' which stof might handle
401
				// or reject Standard library stof parses.
402
				float val = std::stof(args.substr(pos), &nextPos);
403
				coords.push_back(val);
404
				pos += nextPos;
405
			} else {
406
				// Not a number, skip this character (it might be part of P command
407
				// text) For P command, we ideally shouldn't be parsing coords here, but
408
				// we do it generic. Just skip.
409
				++pos;
410
			}
411
		} catch (...) {
412
			// parsing failed, skip char
413
			++pos;
414
		}
415
	}
416
417
	// Any command other than D/J ends an open drawing path before command
418
	// side-effects such as color changes.
419
	if (cmd != 'D' && cmd != 'J') {
420
		liftPen();
421
	}
422
423
	switch (cmd) {
424
	case 'H': // Home - move to origin
425
		printDebug("Plotter: H - Home");
426
		penPosition = gl::vec2{0.0f};
427
		break;
428
429
	case 'M': // Move (absolute) - M x,y
430
		if (coords.size() >= 2) {
431
			printDebug("Plotter: M - Move to (", coords[0], ",", coords[1], ")");
432
			moveTo({coords[0], coords[1]});
433
		}
434
		break;
435
436
	case 'R': // Relative move - R dx,dy
437
		if (coords.size() >= 2) {
438
			printDebug("Plotter: R - Relative move (", coords[0], ",", coords[1], ")");
439
			moveTo(penPosition + gl::vec2{coords[0], coords[1]});
440
		}
441
		break;
442
443
	case 'D': // Draw (absolute) - D x,y[,x,y,...]
444
		// The D command draws a series of absolute line segments
445
		printDebug("Plotter: D - Draw absolute, ", coords.size() / 2, " segments, penDown=", penDown);
446
		setPenDown(true);
447
		for (size_t i = 0; i + 1 < coords.size(); i += 2) {
448
			gl::vec2 target{coords[i], coords[i + 1]};
449
			lineTo(target);
450
		}
451
		setPenDown(false);
452
		break;
453
454
	case 'J': // Draw relative - J dx,dy[,dx,dy,...]
455
		// The J command draws a series of relative line segments
456
		printDebug("Plotter: J - Draw relative, ", coords.size() / 2, " segments, penDown=", penDown);
457
		setPenDown(true);
458
		for (size_t i = 0; i + 1 < coords.size(); i += 2) {
459
			lineTo(penPosition + gl::vec2{coords[i], coords[i + 1]});
460
		}
461
		setPenDown(false);
462
		break;
463
464
	case 'S': // Scale set - S n (0-15)
465
		if (!coords.empty()) {
466
			charScale = std::clamp(int(coords[0]), 0, 15);
467
			updateLineFeed();
468
			printDebug("Plotter: S - Scale set to ", charScale);
469
		}
470
		break;
471
472
	case 'L': // Line type - L n (0-15)
473
		if (!coords.empty()) {
474
			lineType = uint8_t(std::clamp(int(coords[0]), 0, 15));
475
			dashDistance = 0.0f; // Reset pattern phase
476
			printDebug("Plotter: L - Line type set to ", lineType);
477
		}
478
		break;
479
480
	case 'Q': // Alpha rotate - Q n (0-3)
481
		if (!coords.empty()) {
482
			// If a character was just printed, undo the gap
483
			// (the last char before Q should not have the trailing gap)
484
			if (pendingCharGap > 0.0f) {
485
				switch (pendingGapRotation) {
486
				case 0: penPosition.x -= pendingCharGap; break;
487
				case 1: penPosition.y += pendingCharGap; break;
488
				case 2: penPosition.x += pendingCharGap; break;
489
				case 3: penPosition.y -= pendingCharGap; break;
490
				}
491
				printDebug("Plotter: Q - Removed pending gap ", pendingCharGap);
492
				pendingCharGap = 0.0f;
493
			}
494
			rotation = std::clamp(int(coords[0]), 0, 3);
495
			printDebug("Plotter: Q - Rotation set to ", rotation);
496
		}
497
		break;
498
499
	case 'I': // Initialize
500
		// Sets current pen position as origin
501
		origin += penPosition;
502
		penPosition = gl::vec2{0.0f};
503
		printDebug("Plotter: I - Origin set to current pos. New Origin=(", origin.x, ",", origin.y, ")");
504
		break;
505
506
	case 'A': // All initialize
507
		printDebug("Plotter: A - All Initialize (Reset)");
508
		resetSettings();
509
		break;
510
511
	case 'F': { // New line
512
		// Move to top of next line using max height encountered.
513
		// If empty line, default to standard line feed (12 steps + scale effect?).
514
		// If maxLineHeight is set, use it.
515
516
		// Default drop if no characters printed: Height + Gap
517
		// Height = 6 * (1+S), Gap = 2 * (1+S) => Total 8 * (1+S)
518
		// For Scale 1: 8 * 2 = 16 steps.
519
		float drop = (maxLineHeight > 0.0f) ? maxLineHeight : 6.0f * (1.0f + float(charScale));
520
521
		// Add inter-line spacing (Gap)
522
		drop += 2.0f * (1.0f + float(charScale));
523
524
		// Apply Line Feed and Carriage Return based on rotation
525
		switch (rotation) {
526
		case 0: // Normal: Feed Y- (Down), CR X=0
527
			penPosition.y -= drop;
528
			if (penPosition.y < -1354.0f) { // Page End check
529
				flushEmulatedPrinter();
530
				ensurePrintPage();
531
				penPosition.y = 30.0f;
532
			}
533
			penPosition.x = 0.0f;
534
			break;
535
		case 1: // 90 CW (Down): Feed X- (Left), CR Y=0
536
			penPosition.x = std::max(penPosition.x - drop, 0.0f);
537
			penPosition.y = 0.0f;
538
			break;
539
		case 2: // 180 (Left): Feed Y+ (Up), CR X=0
540
			penPosition.y = std::min(penPosition.y + drop, 30.0f);
541
			penPosition.x = 0.0f;
542
			break;
543
		case 3: // 270 CW (Up): Feed X+ (Right), CR Y=0
544
			penPosition.x = std::min(penPosition.x + drop, RIGHT_BORDER);
545
			penPosition.y = 0.0f;
546
			break;
547
		}
548
549
		maxLineHeight = 0.0f;
550
		printDebug("Plotter: F - New Line, drop=", drop, " rot=", rotation);
551
		break;
552
	}
553
	case 'P': // Print - P chrs
554
		if (!args.empty()) {
555
			// Characters after 'P' are printed literally, including spaces.
556
			// CHR$(1) prefix is used to print alternate characters (0-31).
557
			std::string_view text = args;
558
			printDebug("Plotter: P - Printing '", text, "'");
559
560
			bool altChar = false;
561
			for (auto c : text) {
562
				if (uint8_t(c) == 0x01) {
563
					altChar = true;
564
				} else if (altChar) {
565
					// CHR$(1)+CHR$(N) prints character N-64
566
					drawCharacter(uint8_t(c) - 64);
567
					altChar = false;
568
				} else {
569
					drawCharacter(uint8_t(c));
570
				}
571
			}
572
		}
573
		break;
574
575
	case 'C': // Color select - C n (0-3)
576
		if (!coords.empty() && 0 <= coords[0] && coords[0] <= 3) {
577
			auto newPen = unsigned(coords[0]);
578
			if (newPen != selectedPen) {
579
				printDebug("Plotter: C - Select color ", newPen, " (Pen change delay applied)");
580
				selectedPen = uint8_t(newPen);
581
			}
582
		}
583
		break;
584
585
	default:
586
		// Unknown command, ignore
587
		cliComm.printWarning("Plotter: unknown graphic command '", cmd, "'");
588
		break;
589
	}
590
591
	if (cmd != 'Q' && cmd != 'P') {
592
		pendingCharGap = 0.0f;
593
	}
594
	graphicCmdBuffer.clear();
595
}
596
597
gl::vec2 MSXPlotter::toPaperPos(gl::vec2 plotterPos) const
598
{
599
	// Margin constants: center the plotting area on the paper.
600
	// MARGIN_X = (PAPER_WIDTH_STEPS - PLOT_AREA_WIDTH) / 2 = 45 steps (9mm)
601
	// MARGIN_Y = (PAPER_HEIGHT_STEPS - PLOT_AREA_HEIGHT) / 2 = 48 steps (9.6mm)
602
603
	// Convert logical plotter steps to physical paper steps with margins.
604
	// Origin (0,0) is bottom-left of the plotting area.
605
	// X-axis: MarginX + LogicalX
606
	// Y-axis: (PaperHeight - MarginY) - LogicalY (inverted for top-down PNG)
607
	gl::vec2 paperPos = gl::vec2{MARGIN.x, FULL_AREA.y - MARGIN.y}
608
	                  + gl::vec2{1.0f, -1.0f} * (plotterPos + origin);
609
	return paperPos * pixelSize;
610
}
611
612
float MSXPlotter::penRadius() const
613
{
614
	// Sony PRN-C41 uses pens that are likely ~0.4-0.5mm wide.
615
	// Standard step is 0.2mm. Set dot size to 200% of step size for solid
616
	// lines.
617
	// radius = 0.2mm -> line width = 0.4mm,  or 0.6mm for thick line
618
	return pixelSize * ((penThicknessSetting.getEnum() == PenThickness::Thick) ? 1.5f : 1.0f);
619
}
620
621
gl::vec3 MSXPlotter::penColor() const
622
{
623
	return inkColors[selectedPen];
624
}
625
626
void MSXPlotter::drawDot(gl::vec2 pos)
627
{
628
	ensurePrintPage();
629
	assert(paper);
630
	paper->draw_dot(toPaperPos(pos), penRadius(), penColor());
631
}
632
633
void MSXPlotter::setPenDown(bool newState)
634
{
635
	if (newState) {
636
		// Only the first lowering of a stroke emits a touchdown dot.
637
		if (!penDown) {
638
			penDown = true;
639
			drawDot(penPosition);
640
		}
641
	} else {
642
		liftPen();
643
	}
644
}
645
646
void MSXPlotter::liftPen()
647
{
648
	penDown = false;
649
}
650
651
void MSXPlotter::drawLine(gl::vec2 from, gl::vec2 to)
652
{
653
	assert(penDown);
654
655
	ensurePrintPage();
656
	if (0 < lineType && lineType < 15) {
657
		auto halfPeriod = float(lineType + 2);
658
		drawDashedLine(from, to, halfPeriod);
659
	} else {
660
		drawSolidLine(from, to);
661
		dashDistance += length(to - from); // needed ?
662
	}
663
}
664
665
void MSXPlotter::drawDashedLine(gl::vec2 A, gl::vec2 B, float halfPeriod)
666
{
667
	if (A == B) return;
668
669
	gl::vec2 AB = B - A;
670
	float length2 = gl::length2(AB);
671
	float invLen = gl::rsqrt(length2);
672
	float len = length2 * invLen; // for dashDistance, avoids separate sqrt
673
674
	float period = 2.0f * halfPeriod;
675
	float phase = std::fmod(dashDistance, period);
676
	dashDistance += len;
677
678
	float t_start = (phase < halfPeriod) ? -phase : (period - phase);
679
	float t_end = t_start + halfPeriod;
680
681
	// Work in normalized parameter space [0,1] to avoid per-iteration multiplies
682
	float period_alpha = period * invLen;
683
	float alpha_start = t_start * invLen;
684
	float alpha_end = t_end * invLen;
685
686
	// First segment (may be partial at line start)
687
	if (alpha_start < 1.0f) {
688
		drawSolidLine(A + AB * std::max(0.0f, alpha_start), A + AB * std::min(1.0f, alpha_end));
689
		alpha_start += period_alpha;
690
		alpha_end += period_alpha;
691
	}
692
693
	// Middle segments (fully inside [0,1], no clipping)
694
	while (alpha_end < 1.0f) {
695
		drawSolidLine(A + AB * alpha_start, A + AB * alpha_end);
696
		alpha_start += period_alpha;
697
		alpha_end += period_alpha;
698
	}
699
700
	// Last segment (partial at line end)
701
	if (alpha_start < 1.0f) {
702
		drawSolidLine(A + AB * alpha_start, B);
703
	}
704
}
705
706
void MSXPlotter::drawSolidLine(gl::vec2 A, gl::vec2 B)
707
{
708
	assert(paper);
709
	paper->draw_motion(toPaperPos(A), toPaperPos(B), penRadius(), penColor());
710
}
711
712
void MSXPlotter::ensurePrintPage()
713
{
714
	if (paper) return;
715
716
	static constexpr auto MM_PER_INCH = 25.4f;
717
	auto paperSize = trunc(A4_SIZE / MM_PER_INCH * float(dpiSetting->getInt()));
718
	pixelSize = float(paperSize.x) / FULL_AREA.x; // pixels are round (x==y)
719
	paper = std::make_unique<PlotterPaper>(paperSize);
720
}
721
722
void MSXPlotter::flushEmulatedPrinter()
723
{
724
	liftPen();
725
	if (!paper) return;
726
727
	if (!paper->empty()) {
728
		try {
729
			static constexpr std::string_view PRINT_DIR = "prints";
730
			static constexpr std::string_view PRINT_EXTENSION = ".png";
731
			auto filename = FileOperations::getNextNumberedFileName(PRINT_DIR, "page", PRINT_EXTENSION);
732
733
			auto rgb = paper->getRGB();
734
			auto size = rgb.size();
735
			small_buffer<const uint8_t*, 4096> rowPointers(std::views::transform(xrange(size.y),
736
				[&](int y) { return &rgb.getLine(y).data()->x; }));
737
738
			PNG::saveRGB(size.x, rowPointers, filename);
739
740
			cliComm.printInfo("Printed to ", filename);
741
		} catch (MSXException& e) {
742
			cliComm.printWarning("Failed to print: ", e.getMessage());
743
		}
744
	}
745
	paper.reset();
746
}
747
748
void MSXPlotter::resetSettings()
749
{
750
	mode = Mode::TEXT;
751
	escState = EscState::NONE;
752
	selectedPen = 0;
753
	penDown = false;
754
	penPosition = gl::vec2{0.0f, 30.0f};
755
	origin = gl::vec2{0.0f, 1354.0f};
756
	lineType = 0;
757
	dashDistance = 0.0f;
758
	rotation = 0;
759
	pendingCharGap = 0.0f;
760
	pendingGapRotation = 0;
761
	charScale = 1; // Default scale is 1
762
	maxLineHeight = 0.0f;
763
	graphicCmdBuffer.clear();
764
	printNext = false;
765
	terminatorSkip = TerminatorSkip::NONE;
766
767
	// Set up printer settings for plotter
768
	updateLineFeed();
769
}
770
771
void MSXPlotter::moveTo(gl::vec2 pos)
772
{
773
	penPosition = pos;
774
}
775
776
void MSXPlotter::lineTo(gl::vec2 pos)
777
{
778
	drawLine(penPosition, pos);
779
	penPosition = pos;
780
}
781
782
void MSXPlotter::drawCharacter(uint8_t c)
783
{
784
	ScopedAssign savedLineType(lineType, 0);
785
	ScopedAssign savedDashDistance(dashDistance, dashDistance); // value doesn't matter, just restore at the end
786
	// Character glyphs are already vector strokes; keep the pen logically down
787
	// during the call without adding extra touchdown dwell dots.
788
	ScopedAssign savedPenState(penDown, true);
789
790
	auto fontVariant = [&] {
791
		switch (getCharacterSet()) {
792
		using enum CharacterSet;
793
		default:
794
		case International: return PlotterFontVariant::International;
795
		case DIN:           return PlotterFontVariant::DIN;
796
		}
797
	}();
798
	auto glyph = decodePRNC41Glyph(fontVariant, c);
799
800
	float scaleFactor = 1.0f + float(charScale);
801
	float unitX = 0.8f * scaleFactor;
802
	float unitY = 1.0f * scaleFactor;
803
804
	maxLineHeight = std::max(maxLineHeight, 6.0f * unitY);
805
806
	// Transform local (u, v) [0..width, 0..height] to global (px, py)
807
	// Cursor is at the START of the character baseline.
808
	// u=0 is at cursor, character extends in reading direction (+u).
809
	// v=0 is at baseline, character extends upward (+v).
810
	auto transform = [&](gl::vec2 p) -> gl::vec2 {
811
		// Each rotation matrix has columns [uDir, vDir] for that rotation
812
		static constexpr std::array<gl::mat2, 4> rotations = {{
813
			gl::mat2{gl::vec2{ 1.0f,  0.0f}, gl::vec2{ 0.0f,  1.0f}}, //   0°
814
			gl::mat2{gl::vec2{ 0.0f, -1.0f}, gl::vec2{ 1.0f,  0.0f}}, //  90° CW
815
			gl::mat2{gl::vec2{-1.0f,  0.0f}, gl::vec2{ 0.0f, -1.0f}}, // 180°
816
			gl::mat2{gl::vec2{ 0.0f,  1.0f}, gl::vec2{-1.0f,  0.0f}}, // 270° CW
817
		}};
818
		return penPosition + rotations[rotation] * p;
819
	};
820
821
	gl::vec2 glyphPos{0.0f, 0.0f};
822
	for (const auto& command : glyph) {
823
		gl::vec2 nextGlyphPos = glyphPos + gl::vec2{
824
			command.delta.x * unitX,
825
			command.delta.y * unitY,
826
		};
827
		if (command.type == PlotterFontCommandType::Draw) {
828
			drawLine(transform(glyphPos), transform(nextGlyphPos));
829
		}
830
		glyphPos = nextGlyphPos;
831
	}
832
	// Advance cursor to next character position
833
	// Always use full width (char + gap). If Q rotation follows, it will undo the
834
	// gap.
835
	float charWidthOnly = 5.0f * unitX;      // just the character
836
	float charGap = 2.0f * scaleFactor;      // gap between characters
837
	float charAdvance = charWidthOnly + charGap; // total advance
838
839
	penPosition = transform({charAdvance, 0.0f});
840
841
	// Track the gap so Q command can undo it if it follows
842
	pendingCharGap = charGap;
843
	pendingGapRotation = rotation;
844
845
	printDebug("Plotter: Rotation ", rotation,
846
	           " | charWidth ", charWidthOnly,
847
	           " | charGap ", charGap,
848
	           " | charAdvance ", charAdvance,
849
	           " | plotter.x ", penPosition.x,
850
	           " | plotter.y ", penPosition.y);
851
}
852
853
void MSXPlotter::updateLineFeed()
854
{
855
	// Base line feed for scale 1 is 18 (9.0 * 2).
856
	// Formula: 9.0 * (1.0 + Scale) seems consistent.
857
	// Scale 0: 9 * 1 = 9
858
	// Scale 1: 9 * 2 = 18
859
	lineFeed = 9.0f * (1.0f + float(charScale));
860
}
861
862
// Serialization and registration macros
863
INSTANTIATE_SERIALIZE_METHODS(MSXPlotter);
864
REGISTER_POLYMORPHIC_INITIALIZER(Pluggable, MSXPlotter, "MSXPlotter");
865
866
} // namespace openmsx