← Laser Experiments

XCS Format Support

What the tool knows about the .xcs file format, where each rule is written down, and how to check a generated file against the real thing. Everything here targets the xTool F2 on the VANTAGE-ALPHA baseline.

Format and viewer specs locked 2026-03-18

The rule that governs everything else

No speculative implementation

When a feature is not explicitly documented in xcsformat.md, do not guess the JSON. XCSViewer.md §3 makes this a mandatory protocol:

  1. Stop. Do not attempt to guess the structure.
  2. Wait. Say that the feature is unsupported by current knowledge.
  3. Ask. Request an exported .xcs sample from xTool Creative Space that demonstrates it.
  4. Confirm. Implement only after the sample is analysed and xcsformat.md updated with verified facts.

Triggers that call for a sample: new shape types (polygons, stars, bezier curves), new processing modes such as 3D relief, bitmap filters and image adjustments, and grouping or nesting of objects.

The rules you hit most often

Relative geometry

XCS separates an object's origin from its geometric data. Path coordinates are relative to the object's own top-left; the bed position lives only in x and y. Set both to absolute machine coordinates and XCS applies the offset twice, which drops the shape far outside the work area or onto 0,0.

wrong   dPath "M 50 50 โ€ฆ"   x: 50   y: 50   → drawn 50mm from an anchor at 50,50
right   dPath "M  0  0 โ€ฆ"   x: 50   y: 50   → drawn at 50,50

Generate technical paths centred on (width/2, height/2), then set x and y to the top-left position you want. offsetX/offsetY carry the distance from that anchor to the geometric centre. Documented in xcsformat.md §13.

The bed, and where a pattern sits on it

RuleValue
Working surface0,0 to 100,100 mm
Default centre50,50 mm — every generated pattern
Coordinatesmm from top-left
Fill processingCOLOR_FILL_ENGRAVE, reached through isFill: true
Path processingVECTOR_ENGRAVING, reached through isFill: false

Never hardcode a processing-type string in a pattern component — use the isFill abstraction and let the exporter choose. From GEMINI.md §1 and §4.

Three-point sync

Geometry is joined to its laser settings by matching GUIDs, and a file that breaks the join opens without settings. Two structural tests every export must pass:

Plus the boilerplate: 36-character UUIDs, every layerColor present in canvas.layerData, every Map wrapped as {"dataType":"Map","value":[โ€ฆ]}, all four operation nodes per shape, and root metadata extId: "GS006" / extName: "F2". xcsformat.md §2, §6, §7.

Text grows upward from a left-edge baseline

XCS anchors text at the left edge of the baseline and glyphs grow up from it, so a label placed at the bottom of a grid overlaps the grid rather than sitting under it.

ScenarioAnchorGrows
Horizontal, 0°Left-edge baselineRight (+X)
Vertical, −90°Top-edge baselineDown (+Y)
Centre horizontallytargetX − width/2—
Centre verticallytargetY + width/2—

Text also has to be baked: a TEXT object carries charJSONs, one PATH per character, and fontData with metrics and glyph paths. Without them the text is invisible to F2 offline processing. xcsformat.md §4, §11, §12 and XCSViewer.md §5.

One module owns the format

xcs-system.js is the only module permitted to generate and manage formal XCS structures. It handles text baking, node boilerplate and the conversion to the left-baseline anchor. XCSExporter and XCSIR are thin compatibility bridges over it. XCSViewer.md §2.

PropertyInternalFormal XCS
Coordinatesmm from top-leftfactor in scale and offsetX/Y
Dimensionsw, h in mm, final sizewidth, height, may be unscaled
Text anchorcentre or baselineleft-edge baseline, strict
Colourshex, #5b9bd5lineColor decimal, fillColor hex

Needs and missing concepts

Gaps found by exporting every Shape Fill style to XCS and reading what came out. The canvas preview and the engraved result are not the same picture, and these are the reasons why.

Buried geometry is still engravedblocking

The canvas hides whatever a later shape paints over. The recorder does not — every fill() and stroke() becomes a shape in the file, including the parts no one will ever see. Nothing removes them afterwards.

StyleShapesFully buriedPartly buriedEngraved mm²Visible mm²Wasted
Gears4,3201,223 — 28%2,2069,4901,97979%
World Coins6,3582,518 — 40%1,30210,4412,45077%
Buttons2,000267 — 13%1,1868,7212,21975%
Pipes and Gears97191 — 9%5605,5111,66670%
Rows of Ducks1,755411 — 23%1,1606,2632,42261%
Circuit Board61164 — 10%4552,05690556%
Painted Lady1,375350 — 25%8094,3002,00853%
Gems2,19482 — 4%1,9442,3271,12752%
3D Glossy49804222,4731,19652%
3D Basic41603292,4201,21550%
Autumn Leaves1,475116 — 8%1,0402,9401,55847%
Flat2252 — 1%1502,0971,19743%
Christmas Cookies87175 — 9%3912,5531,58538%
Jigsaw, Solved64906162,8752,25622%

Two costs. Burn time is spent on invisible geometry, and where a hidden shape and the shape above it are on different layers, that area is engraved twice at two different powers. Gears engraves 8,929 mm² to show 1,923.

Both kinds are worth removing. Whole shapes disappear in quantity — 2,518 of World Coins' 6,358, 1,223 of Gears' 4,320 — and those can simply be deleted. But they are never the bulk of the waste: partial burial is what every style is full of, and three styles have no wholly hidden shape at all while still wasting half their engraving. Subtraction is the operation that covers both, and culling falls out of it as the case where nothing is left.

Jigsaw's solved fit is the closest thing to a control, and even there 22% is wasted: the pieces tile without overlapping, but each piece's outline covers its own fill.

Needed: occlusion culling before export, or boolean subtraction of covered regions. Neither exists.

Measured, on real designs. tools/occlude-check.mjs runs the pass over an exported style and checks the one thing that must not move: the binary ink mask before against after. Anything removed was invisible, so a difference there is a bug rather than a trade-off.

StyleShapesWholly coveredTrimmedEngraved mm²Picture movedOcclusion time
Gears4,1221,0602,5018,788 → 1,9290.009%26.7 s
World Coins6,1942,1433,1559,547 → 2,3750.004%5.9 s
Flat23501931,926 → 1,1920.046%0.05 s

In every case the engraved area after the pass equals the visible area measured independently by the stencil — 1,929 against 1,929 for Gears, 2,375 against 2,375 for World Coins. Two separate methods arriving at the same number is the strongest check available short of the machine.

It is too slow to run on a slider drag, and that is a placement decision rather than a blocker. Gears takes 26.7 s and World Coins 5.9 s. Gears is the slower despite having fewer shapes because its capsule strokes carry far more points each. The pass belongs in the debounced XCS project build, not in the canvas render loop.

Studio copes at full volume. A real flattened Gears — 3,062 shapes, 1,664 of them carrying holes, 16.4 MB against 36.9 MB unflattened — opens, renders every gear's teeth and interior rings, and screenshots in 201 ms. See the capture. The nine-shape round trip below proved the representation; this proves it at the volume a real design reaches.

The approach is viable. Subtraction produces geometry this file has never asked Studio to draw, so the buried-geometry render test hand-authors each outcome and puts it through the round trip before any of it is built. Studio drew all nine elements within 0.4 mm of where the file put them and answered all eleven probes correctly: a hole stayed a hole, a hole with an island inside it kept both, a concave remainder kept its bite rather than being convex-hulled, and two colours meeting on an exactly shared edge met with no gap. Compound paths with evenodd are therefore a sound representation for occlusion. Run 2026-09-16 against Studio 1.5.8; see the capture.

Two things the round trip cannot settle. A merged shape and the two overlapping shapes it replaces render identically, so no picture will ever show whether merging happened — the overlap that engraves twice is visible only on the part. And Studio draws a 0.05 mm sliver quite happily, which says nothing about whether the F2 engraves it; the threshold below which a boolean remainder should be discarded is a number for the machine, not for the screen.

Measured by xcs-module/tools/buried.mjs on 2026-09-16, Pendant preset, margin 0, at 4,096 px across the bed — 0.0244 mm per pixel, finer than the beam. The tool walks from the topmost shape down, keeping a bitmap of what the shapes above have covered, and paints each shape alone inside its own bounding box: the pixels it covers are the area it started with, and the ones not already in the bitmap are what stays visible. Cross-checked against exact polygon subtraction in tools/occlude-check.mjs, which finds 1,083 wholly covered shapes in Gears where this finds 1,223 — the two differ by the raster resolution and the seed, not by method.

Two earlier counts of this section were wrong in opposite directions and are worth recording. The first used bounding boxes and reported Gears at 94% fully buried; a box inside another box is not covered geometry, so that was an upper bound. The second encoded each shape's index as a colour, painted them in order, and counted the pixels each index still owned — it reported 6. Consecutive indices differ by one in the blue channel, so an antialiased edge between a shape and the shape covering it blends to a colour that is still a valid index, and buried shapes were handed phantom edge pixels. That run's own blend detection reported 0.0%, because no blend ever looked invalid. Exact polygon subtraction is what caught it.

Layer count runs far past what the tool indexesneeds a sample

Every distinct flattened colour becomes its own layer with its own parameter block. The 3D shading in these styles generates shades that never passed through mapPaletteToLaser, so the count is not bounded by the palette.

StyleLayers in the exported file
World Coins51
Circuit Board38
Autumn Leaves37
Buttons26
Jigsaw7

XCS_LAYERS in constants.js lists eight. The viewer's shape list looks a colour up in it and simply omits the L1…L8 tag when it is not found, so a 51-layer export degrades quietly rather than failing.

Unknown: whether XCS Studio or the F2 accepts 51 parameter blocks at all. Untested, and the no-speculation rule applies — this needs an exported sample with many layers before we guess. Needed either way: quantise colours to the laser palette before emitting layers.

Fixed at the source. The generator always picked its base colours from the palette; the draw functions then invented more — 33 shadeColor() tints computed as arbitrary multiples, 100 hard-coded hex literals, 37 alpha washes whose composited result depended on what lay beneath, and six arrays of literal colours including the resistor colour codes. None of them had passed through the palette.

Rather than correcting colours after the fact, the drawing is now bound to the palette at the point a colour is chosen: paletteNear() returns the palette entry closest to a colour, and shadeColor() keeps its old signature but walks the palette ordered by luminance instead of multiplying channels, so above 1 lightens and below 1 darkens by steps that exist. Twelve different near-blacks collapsed to one OUTLINE constant, because the rule is the palette plus black, singular. Both canvases draw the same thing because both are handed the same thing.

StylePaletteLayers beforeLayers now
World Coins9~4009
Gems7857
Buttons9469
Circuit Board5447
Autumn Leaves82910
Painted Lady102315
Christmas Cookies7168
Gears91310
Pipes and Gears91211

A style that matches its palette exactly is drawing only what it was given. The few that sit one to four layers over still have a colour leaking in from somewhere — Painted Lady is the worst at 15 against 11 — and each is a named variable to chase rather than a structural problem.

The 2,000-shape guard is not implementedbuild

patterns.md requires a thinning or resolution guard above 2,000 shapes. Gears exports 4,434 — 2.2× over — at 40.7 MB. Jigsaw caps itself at 1,800 pieces, but it is the only style that does.

StyleShapesFile
Gears4,43440.7 MB
Autumn Leaves1,4094.9 MB
Painted Lady1,2405.3 MB
Jigsaw6514.6 MB

Curves are sampled, not carriedneeds a sample

The recorder speaks M L Q ARC ELLIPSE RECT Z. There is no cubic bezier, so every curve is flattened to a polyline before it reaches the file — the main reason a 4,434-shape export is 40 MB. Bezier curves are explicitly on XCSViewer.md's ask-for-a-sample list, so emitting C is blocked until a sample from Creative Space proves the structure.

Everything else found

GapEffectKind
clip() content dropped Anything drawn inside a clip is skipped: button marbling, gourd rib and wart texture. Silhouettes and base colours survive. sample
fillText() dropped Coin denominations and years, resistor codes (104K, K5%) never engrave. Glyph baking exists for real TEXT objects but is not wired into the recorder. build
calculatePathLength ignores C, A, H, V Measures M L Q Z only, so any path built from other commands reports a short or zero run length and time estimates are wrong. build
calculatePathBBox counts control points Walks every coordinate pair, including bezier handles that sit outside the curve, so a shape's reported box is larger than its outline. build
No alpha xcs-canvas.js never reads it; every shape renders opaque. Translucent fills are composited against the backdrop at record time, which matches visually but cannot represent real transparency. sample
One stroke width for the whole design XCS draws paths as hairlines, so strokes become filled capsules at a single physical Outline Width. Per-shape line weights in the artwork are lost. build
Grouping and nesting Undocumented in xcsformat.md and on the ask-for-a-sample list. Every shape is emitted flat. sample

Measured on the Pendant preset at default settings, one style per run, seeds pinned so the comparison is like for like.

Plans

Worked plans for closing the gaps above. Each carries the change, how to get there from here, what could go wrong, and how it will be proved.

Extend the XCS verifier

build first

The rule. Nothing counts as verified until a real .xcs has been opened in xTool Creative Space and matched against what our renderer drew. Every refinement in the plans below gets a test here first, and the test ships with an export button. our renderer = Creative Space = the machine

This plan comes first because it is the instrument for the other three. Without it, each of them is a claim.

What the verifier does today

XCS Module Validation runs 16 checks in a single ungrouped list: PROJECT Init, TEXT Baking, RECT / SCORE, CIRCLE / ENGRAVE, BITMAP / COLOR, IMAGE Passthrough, Closed Filled Path, OPEN PATH (Spiral), Compound Vectors, Compound (Reversed), Boundary Frame, Capsule Stroke, Arc Sampling, Color Fidelity, Scale Stress and RUNTIME CRASH.

7 of those already measure rendered geometry rather than structure. verifyGeometry() reads the rendered SVG's own getBBox() and compares it against expected millimetres, at a 0.3 mm tolerance, for CLOSED_PATH, SPIRAL, COMPOUND, COMPOUND_REV, FRAME, CAPSULE_STROKE and ARC_SAMPLE. Its comment records why it exists: structural assertions like !!dPath never caught the double-translate placement bug, because _renderPath() applies its own translate on top of whatever dPath it is given.

Three round-trip exports exist already — Test Export (PNG + XCS), Test Export (Stress) for the 1000-item project, and Text Cal Export. They are buttons a person remembers to press, not tests with a recorded expected result.

The restructure

  1. Put the existing 16 into a Unit Tests tab. Unchanged in content and order. They assert that the file is structurally correct.
  2. Add a Render Tests tab. Each entry is a scenario rather than an assertion: it draws something, renders it both ways, compares, and offers the .xcs for Creative Space.
  3. Its first entry is Geometry Baseline — the seven existing verifyGeometry() checks, moved across intact. They are the baseline every later entry is measured against, and moving rather than rewriting them keeps that baseline honest.
  4. Give every render entry the same four parts: what it draws, the automated comparison and its tolerance, an export button, and the expected observation in Creative Space written down before anyone opens it. A round trip with no recorded expectation proves nothing.

The render tests to add, in the order the work needs them

#EntryDrawsPasses whenRound trip confirms
0Geometry Baseline The seven existing shapes Every getBBox() within 0.3 mm of expected Shapes land where the viewer says. Already the de facto baseline.
1Layer Budget A field in 1, 4 and N colours plus outlines layerData holds exactly colours + 1, and every shape's colour is in that set Creative Space lists that many layers, with the powers we intended.
2Stroke Width Ladder Lines at 0.05, 0.1, 0.2, 0.5, 1 and 2 mm Each exported capsule measures its nominal width The ladder is visibly six different weights, and says which ones the beam actually resolves.
3Overlap and Occlusion Stacked opaque shapes, fully and partly covered Shape count matches the culling policy once one exists Whether a buried shape burns. The one test that cannot be answered off the machine.
4Text Fidelity Baked text at 2, 4, 8 and 16 mm Glyph paths present, advances correct, anchored left-baseline The smallest legible size. Extends the existing Text Cal Export into a test.
5Curve Fidelity Circles, arcs and a jigsaw connector at several radii Sampled polyline stays within tolerance of the true curve Where sampling starts to show as faceting on the engraving.
6Translucency and Clip Layered translucent fills; a clipped texture Flattened colour matches the composite; clipped content accounted for Whether either concept survives at all — this is the sample the no-speculation rule wants.
7Style Parity Each Shape Fill style, canvas against XCS Raster difference within that style's recorded tolerance The migration gate. One entry per style, each carrying its own number.
8Budget and Scale Progressively denser fields Shape count and file size stay inside the declared budget Where Creative Space or the F2 starts to struggle. Extends Test Export (Stress).

Round trips need a record, not a button

The three export buttons today produce a file and nothing else. Each render entry should instead carry a short result log — date, XCS version, what was observed, pass or fail — stored beside the test so the answer survives the person who ran it. That is the gap between manual-test-loop.md as a procedure and as evidence, and it is the same gap exportpatches.md is filling by hand for the export format.

What could go wrong

  • Round trips are manual and will lag. The automated half runs in a second; the Creative Space half needs a person. Entries will sit with a green automated result and no round trip, which must be visible as its own state rather than read as a pass.
  • Tolerances get tuned until they pass. Same risk as the migration plan. A tolerance is a reviewed number with an image attached, not a knob.
  • Raster diffing in a browser is fiddly. Canvas to bitmap is easy; SVG to bitmap at matching scale is less so, and antialiasing differences will show as a noise floor that has to be measured before any threshold means anything.
  • Tests 3 and 6 may be unanswerable for a while. Both wait on evidence from the machine, so they should ship as entries in a known-unanswered state rather than be left out and forgotten.

Test and validation of the verifier itself

  1. The baseline still passes after the move. The seven geometry checks must report identically before and after being relocated into the Render Tests tab — the move is a refactor and has to be provable as one.
  2. Deliberate breakage. Each new entry gets a known-bad variant that must fail it. A render test that has never failed has not been shown to test anything.
  3. Noise floor first. Measure the raster difference of a design against itself before setting any threshold, so tolerances are set above the measured floor rather than guessed.
  4. Counts on the page. The tab badges read passed, failed, and awaiting round trip, separately.

Answers this needs first

  • Do round-trip results live in the page, or in a file beside manual-test-loop.md?
  • Should Style Parity be one entry per style, or one entry with a per-style table?
  • Is a red round trip a build failure, or a recorded known gap?

The design has no edge

resolved

The problem. The HTML canvas clips drawing at its own edge. The recorder has no edge to clip against, so everything a style scatters past the canvas is exported anyway โ€” landing off the machine bed and changing where the rest of the design sits. gears exports 135 × 152 mm onto a 100 × 100 mm bed

Where it stands. Squaring the canvas to 1200 × 1200 px — the bed itself — and centring each composition on 50,50 closed this. Measured from the exported displays, Pendant preset, margin 0, on 2026-09-16, every style lands well inside the bed. gears 28.6–70.6 × 15.6–83.2 mm · coins 28.4–71.5 × 15.9–83.3 mm · autumn leaves 28.5–70.4 × 16.5–83.8 mm · jigsaw 31.5–68.5 × 18.9–81.1 mm

That measurement covers the Pendant preset at margin 0. A preset whose hull reaches further, or a negative margin, is not covered by it and can still push past the edge.

How it was found

Rendering Gears both ways and diffing the two rasters gave 67% of pixels differing, which is far too much for the “something looks slightly off” it started as. Registering the two images by their ink showed why: the canvas render is 4:3 and the XCS render is square, and both are full of ink to the very edge. The XCS view is not a worse drawing of the same design — it is a different crop of a larger one.

The canvas is 1200 × 900 px, which at PX_TO_MM is 100 × 75 mm. Measured extents of what actually gets exported:

StyleExported extentSize
Gearsx −17.5 … 117.5, y −25.8 … 125.8135 × 152 mm
World Coinsx −28.1 … 128.1, y −46.2 … 146.2156 × 192 mm
Jigsawx 31.1 … 68.8, y 18.6 … 81.338 × 63 mm
Pipes and Gearsx 30.8 … 69.2, y 18.4 … 81.638 × 63 mm

Counting shapes against the bed separates the styles that scatter from the styles that fill:

StyleShapesOn the bedStraddlingEntirely off
World Coins26043%17%39%
Christmas Cookies6333%33%33%
Buttons6455%45%0%
Gems16168%32%0%
Autumn Leaves1,40987%12%1%
Gears4,43494%5%1%
Rows of Ducks92100%——
Jigsaw651100%——

A third of World Coins is entirely off the bed. It cannot engrave, and it was never visible in the preview that approved it. Gears loses only 6%, but that 6% is enough to move the ink bounding box from 100 × 75 mm to 135 × 152 mm — and since centerOffsetX/Y centres that box on 50,50, the whole design shifts. That is the visible difference, not the shading.

The decision needed

Three answers, and they are not equivalent. This is the approval this plan is asking for.

OptionWhat it means
AClip to the canvas Treat the canvas rectangle as the design surface and clip every shape to it, the way the preview already does. Parity by construction, and nothing can land off the bed. Costs real polygon clipping — flatten each straddling shape and run Sutherland–Hodgman against the rectangle. Changes the output of six styles.
BScale to fit Keep every shape and shrink the whole design until it fits the bed. Nothing is lost, no clipping code is needed. But the engraved size stops matching what the size sliders imply, and a style that scatters far shrinks the part you actually care about.
CCentre on the canvas, keep the overflow Compute centerOffset from the canvas rectangle rather than the ink bounding box. One line. It fixes the framing, so the XCS view shows what the preview shows — but shapes outside the canvas still export and still sit off the bed, silently.

Recommendation: A, with C as the same change's first half. The preview is already drawing the honest picture; the exporter is the thing that disagrees with it. B hides a real problem by making everything smaller, and C alone leaves content off the bed where nobody looks.

Two gaps held open by a toggle

Engraved text and clipped texture are the detail the recorder cannot carry, and neither is a decision to take by default. The Shape Fill panel's Detail the Export Drops section turns each one off at the canvas: on, the canvas draws it and the export omits it, so the two renders genuinely differ; off, neither draws it and they agree on its absence. Both default to on, so nothing changes until the question is settled.

ToggleWhat it coversMeasured effect
Engraved TextWorld Coins denominations and years, Circuit Board resistor codesWorld Coins, same page load: 3.62% of canvas pixels change and ΔL moves from −4.28 to −2.83, taking the style inside the antialiasing floor.
Clipped TextureButtons marbling, Autumn Leaves gourd ribs and wartsButtons, same page load: 3.71% of pixels change while ΔL moves 0.08 — the marbling lightens and darkens in equal measure, so mean luminance alone cannot see it.

The change

  1. Base centerOffsetX/Y on the canvas rectangle rather than the recorded ink bounds. On its own this makes the XCS view frame the design the way the canvas does.
  2. Drop shapes whose bounding box lies entirely outside the canvas — 39% of World Coins, 1% of Gears — before any clipping work, since they need none.
  3. Clip the straddlers. Flatten each to a polyline at the existing tolerance and run Sutherland–Hodgman against the rectangle. Closed fills clip cleanly; an open stroke has to be clipped as a polyline and may fall into several pieces.
  4. Assert the result: no exported shape may extend beyond the bed, for any style, at any settings.

What could go wrong

  • Clipping a stroke is not clipping a fill. A capsule or ribbon is already a filled polygon by the time the recorder sees it, so it clips as a fill — correct, but its round cap becomes a straight cut at the boundary, which is right for a bed edge and wrong-looking if anyone expects a cap.
  • Styles that rely on overflow will look cropped, because they are. Coins scatters well past the canvas by design and currently gets away with it. After the change, its edge is the bed edge.
  • The centring mandate interacts. GEMINI.md requires the pattern centred on 50,50. Centring the canvas rather than the ink is compatible, but only once the ink is inside the canvas.
  • Shape counts drop. Coins loses 39% of its shapes outright. That is a saving, not a regression, but every count recorded before this change stops comparing.

Test and validation

  1. Extent assertion. For all fourteen styles, both modes, several densities: every shape's box inside 0–100 mm on both axes. This is the check that would have caught it, and it is four lines.
  2. Raster diff, canvas against XCS canvas. The number that started this: currently 67% of pixels differing on Gears. Track it down per style and record what remains once framing is no longer the cause.
  3. Shape count before and after, per style, so the clipping is visible as a number rather than discovered later.
  4. Round trip on the two worst. World Coins and Christmas Cookies through the manual test loop — they are the styles where the change is most visible, so they are the ones to look at in Studio.

Answers this needs first

  • Clip, scale, or keep the overflow?
  • If clipping: is the canvas rectangle the design surface, or should the shape outline (the pendant, the cuff) be the clip boundary instead?
  • Should a style that loses a third of its shapes to clipping warn, or just clip?

Colour and layer management

blocking

The rule, for every style. A generated file carries one layer per colour actually in play, plus one black layer for outlines. Nothing else. This holds for Gears, Coins, Circuit Board and the rest exactly as it holds for Jigsaw. layers = colours in play + 1

Scope and history

Palette-bounded layers were always the intention. A laser engraves a fixed set of powers, the palette is that set, and a file that asks for more colours than the palette holds is asking for something the machine cannot do. The true-colour path — every shade the draw code invents becoming its own layer — was drift, not a decision, and it went unnoticed for a long time. This plan is the correction, and it applies to all fourteen styles.

Where the extra layers come from

Every distinct colour the recorder sees becomes a layer, and the draw code emits far more colours than the palette holds. Jigsaw is quoted first only because its extras are few enough to count by eye; the arithmetic and the fix are the same for every style:

Piece ColorsLayers todayBreakdown
141 palette + #15171a board + #3a3f45 board edge + #141414 outline
474 palette + the same three
9129 palette + the same three

The illustrated styles are far worse, because their shading generates colours that never passed through the palette at all:

StyleLayersFrom the paletteGenerated
World Coins51150
Circuit Board38236
Autumn Leaves37433
Gears1495

Choosing a real laser palette instead of True Color does not reduce this. Measured across seven styles, the layer count is identical either way — Coins is 51 both times. mapPaletteToLaser runs once, over the decorative palette, before any drawing happens. Everything invented after that point escapes it entirely, so the leak is downstream of the mapping in every style. That is why the fix has to sit at the point colours become shapes, not at the point the palette is chosen.

Three sources, in order of how much they contribute:

  • Shading derivatives. shadeColor() lightens and darkens a palette colour for 3D relief, and each result is a new colour. Translucent highlights add more, since flattenColor() composites them against whatever is behind and emits the composite.
  • Fixed decorative colours. Circuit's copper and gold, coin metals, gear silver — hardcoded in the draw functions and never routed through mapPaletteToLaser.
  • The board. drawBase() fills and strokes the shape outline in a per-style colour, adding two layers that are backdrop rather than pattern.

The change

Quantise at the single choke point rather than in fourteen styles' worth of draw code. RecordingCtx._emit is the only place a colour becomes a shape, and buildXCSProject is the only place shapes become layers.

  1. Tag strokes. _emit already knows isThickStroke. Record it on the shape so outlines are separable from fills.
  2. Build the allowed set. After the recording run, read the engine's currentColorPool — the exact subset pickColorSubset chose, already mapped through resolveRenderPalette to real laser entries. That is the N.
  3. Snap every fill to the nearest member of that set, and every stroke to the single black outline layer.
  4. Decide the board explicitly. Either drop it from the export, since an unengraved area is the material itself, or let it claim one palette entry by choice. It must not create its own layer by accident.
  5. Assert before returning. buildXCSProject refuses to hand back a project whose layer count is not N + 1.

Transition

  1. Land the tagging and the assertion behind a Layer Mode control offering Palette and Every colour, defaulting to Every colour, so nothing changes on the first deploy and both outputs can be exported from the same design and compared.
  2. Walk the fourteen styles, recording the before and after layer count and a canvas capture of each. Jigsaw and the flat styles should be visually unchanged; the 3D and illustrated styles will not be.
  3. Flip the default to Palette once the captures have been reviewed. Keep Every colour available for one release so an older .rnr can still reproduce its original export.
  4. Remove Every colour, and with it the legacy path.

What could go wrong

  • The 3D styles flatten. 3D Glossy and 3D Basic exist entirely to paint highlights and shadows. Snapping those to the palette collapses a glossy pipe to a flat one. Arguably honest — the engraver has N powers, not a gradient — but it is a visible regression and needs to be seen before the default flips.
  • Circuit Board's fixed colours. Its Colors slider is disabled and its description says the realistic copper, gold and resistor bands bypass the palette by design. Whether that was a decision or another piece of the same drift is worth settling before it is granted an exemption — a board the F2 can actually engrave needs its colours to be powers like any other style's.
  • Nearest-colour in RGB is perceptually wrong. colorDistance2 is a plain squared RGB distance, which misjudges dark and saturated pairs. Two shades a viewer reads as different can snap to one entry while two they read as the same split across two.
  • Layer order shifts. Layers carry the processing sequence under pathPlanning: "custom". Merging colours changes which shapes share a layer and therefore the order they burn in.
  • Black may not exist in the palette. The outline layer needs a real power. If the laser palette has no near-black entry, the darkest available one stands in and the outline is not as dark as the preview suggests.
  • Older exports stop matching. Anything already sent to the machine was built the old way; re-exporting the same .rnr will not produce the same file.

Test and validation

  1. The rule itself. For all fourteen styles, both modes, at Colors 1, 4 and maximum: assert Object.keys(canvas.layerData).length === N + 1.
  2. No stragglers. Assert every shape's layerColor is either in the allowed set or is the outline black — a set-membership test, not a count, so a merge that loses a colour is caught.
  3. Integrity holds. Re-run both joins from xcsformat.md §6 after the merge, since rewriting a shape's layer touches layerData and the device map together.
  4. Visual delta. Capture the canvas for every style before and after and put them side by side. The flat styles and Jigsaw must be pixel-identical; the rest are a judgement call to be made from the captures, not from the diff.
  5. Shape count unchanged. Quantising colour must not add or drop geometry. Assert the shape count is identical across the two layer modes for the same seed.
  6. On the machine. One style through the manual test loop into Creative Space, confirming the layer list reads as N + 1 with sane powers — the only check that proves the F2 accepts it.

Answers this needs first

  • Does the board engrave at all, or is it backdrop?
  • Was Circuit Board's palette bypass intended, or is it the same drift? If intended, its fixed colours become its palette rather than an exemption.
  • Is the black outline layer always black, or the darkest entry in the active laser palette?

HTML and XCS renders at parity

blocking

The rule. Switching Render Target changes how a design is stored, never what it looks like. Anything the canvas draws reaches the XCS project, or the canvas stops drawing it. canvas draw operations = XCS shapes

The deficit today

Counting what the canvas is asked to draw against what the recorder emits, same seed, Pendant preset, default settings:

StyleCanvas opsXCS shapesLostWhy
Autumn Leaves1,9761,409567 — 29%all clipped
Buttons796415 — 19%clipped; 43 more flattened from translucent
World Coins2602600but 62 text draws dropped
Circuit Board6936930but 31 text draws dropped
Gears4,4344,4340—
Jigsaw6516510—
Pipes and Gears9679670—
Painted Lady1,2401,2400—

Geometry parity is already exact for most styles. The breaks are four, and each has a different cost:

  • Clipped drawing is dropped. 567 operations in Autumn Leaves, 15 in Buttons — gourd rib and wart texture, button marbling.
  • Text is dropped. 62 draws in Coins, 31 in Circuit Board — denominations, years, resistor codes.
  • Translucency is baked. 43 fills in Buttons are composited against their backdrop at record time. The still image matches; the concept does not survive.
  • Drop shadows vanish. RecordingCtx declares shadowColor, shadowBlur, shadowOffsetX and shadowOffsetY, and nothing ever reads them. Gears is the only style that uses them — 218 of its 2,674 fills, all from drawSteampunkGear — and losing them is the single largest visual difference in that style. The shadow is what separates one gear from the one behind it; without it the field reads flat and the shapes merge.

What has to be resolved first

Parity cannot be declared until each of these is either implemented or deliberately closed off, and two of them are gated by the no-speculation protocol rather than by effort.

#GapKindNote
1Per-shape stroke widthshippedRecordingCtx.stroke() converts the recorded lineWidth with PX_TO_MM and builds the capsule at that width; cfg.outlineWeight scales every width together. See the measured result below.
2Text baking in the recorderbuildThe machinery exists for real TEXT objects — charJSONs and fontData per xcsformat.md §11. It is simply not wired to fillText(). Until it is, the panel's Engraved Text toggle decides whether the HTML canvas draws what the export cannot carry.
3Colour and layer managementblockingThe plan above. Parity in colour is meaningless while one design emits 51 layers.
4Clip pathsneeds a sampleNo documented equivalent. Either an .xcs sample proves one exists, or the affected textures are cut from the canvas so both renders agree on their absence. The panel's Clipped Texture toggle makes that second option reversible while the question is open.
5Alphaneeds a samplexcs-canvas.js never reads it. Flattening is a reasonable permanent answer — but that is a decision to record, not a gap to leave open.
6Drop shadowsdecisionA laser has N discrete powers and no blur, so a soft shadow cannot be engraved as drawn. Either emit a hard offset silhouette at a darker palette entry — one extra shape per gear, restoring the depth cue — or drop shadows from the canvas as well, so both renders are flat and honest. Doing neither leaves Gears looking like two different patterns.

Measured parity

Both captures come from the same page load, Pendant preset, brushed steel blank, Outline Weight 1×, margin 0. Mean luminance runs 0–255 over every non-transparent pixel; a style is at parity when the two canvases differ by less than the ±3.5 antialiasing floor the renderers set between them. Measured 2026-09-16.

StyleXCS shapesHTML LXCS LΔL
Flat251176.2176.5−0.3
3D Basic429158.2158.5−0.3
3D Glossy488167.3167.30.0
Illustrated973136.8137.6−0.7
Circuit Board592174.4174.6−0.3
Painted Lady1,184149.2149.9−0.7
Ducks1,768172.6169.3+3.3
Gears4,13590.991.4−0.5
Christmas917121.5122.6−1.1
World Coins6,278102.3105.8−3.5
Gems2,185153.8154.8−1.0
Buttons1,929182.2183.6−1.4
Autumn Leaves1,379129.7129.3+0.4
Jigsaw65286.287.1−0.9

Two defects closed together here, and only one of them was the stroke width. Forcing every stroke to a single physical width was 11× too wide for Flat's gear rings, a few tenths of a millimetre apart on a 6.7 mm gear, and 11× too narrow for the 27 px black underlay drawPipeOutline draws to make Illustrated's outlines. The second defect was in this canvas rather than the exporter: viewer.js's restyleInPlace repeated the fill and stroke logic instead of calling it, and set stroke on filled shapes without a stroke-width. SVG then defaults to one user unit, and a user unit on this bed is a millimetre, so every restyled shape wore a 1 mm black band and Flat's gears painted as solid black discs. Both canvases now call applyPaint() in xcs-canvas.js, so the logic has one home.

The change

  1. Make the deficit visible. Land the op-count harness first, as a test that reports canvas operations against XCS shapes per style. It is the instrument for everything below and it already exists in rough form.
  2. Carry stroke width through. Done. _emit takes the recorded lineWidth, converts with PX_TO_MM, and builds the capsule at that width. The panel's Outline Weight slider scales the artwork's own weights rather than replacing them: 1× engraves each line as drawn, 0× means no outlines. save() and restore() now carry the paint state, since a lineWidth set inside a save block has to stop applying at the matching restore.
  3. Bake text. Route fillText() through the existing glyph path, emitting one PATH per character at the recorded transform.
  4. Close clip and alpha by decision. Once 1 to 3 land, the residual is only these two. Either a sample arrives and they are implemented, or the canvas stops drawing what cannot be exported — parity by subtraction is still parity, and is honest about what the machine can do.
  5. Assert it. The harness becomes a gate: a style whose counts diverge fails.

Transition

  1. Harness first, reporting only. Record a baseline for all fourteen styles so every later step is measured against it rather than against memory.
  2. Stroke width, then text. Both add shapes to the export, so file sizes rise — Autumn Leaves most of all. Re-measure against the 2,000-shape guard at the same time.
  3. Colour and layer plan lands.
  4. Clip and alpha resolved by sample or by subtraction.
  5. Harness flips from reporting to failing.

What could go wrong

  • Parity makes the files bigger, not smaller. Every gap closed adds geometry. Autumn Leaves gains 567 shapes before anything else changes, and Gears is already 2.2× over the shape guard.
  • Real line widths may not be printable. A canvas lineWidth of 1 px is 0.083 mm, below what the beam resolves. Widths need a floor, and the floor changes the drawing.
  • Text at these sizes may be unreadable. A coin denomination is a few millimetres wide. Baking it faithfully may produce something that engraves as a smudge — in which case dropping it, and dropping it from the canvas too, is the better answer.
  • Parity by subtraction is a product decision. Removing gourd texture and button marbling from the canvas makes the two renders agree by making the preview worse. That is a call to make deliberately, not one to arrive at because a sample never turned up.
  • Counting operations is not comparing pictures. Equal counts do not prove equal output; the raster comparison below is what actually settles it.

Test and validation

  1. Operation count. Per style, per mode: canvas fill plus stroke calls against XCS shape count, with the dropped ones attributed to clip, text or empty path. Cheap enough to run on every change.
  2. Raster diff. The real test. Render the canvas to a bitmap, render the XCS project's SVG to a bitmap at the same size, and compare pixel by pixel. Report a percentage per style and keep the difference image. This catches what counting cannot — a shape that exports but lands in the wrong place or the wrong colour.
  3. Per-style budget. Each style declares its acceptable difference. Jigsaw and the flat styles at zero; the illustrated styles at whatever the captures justify, written down rather than assumed.
  4. Regression on shape count. Track shapes and file size per style against the baseline, so the cost of each closed gap is visible as it lands.
  5. On the machine. The style with the largest remaining difference through the manual test loop. A raster diff proves our two renders agree with each other, not that either agrees with the F2.

Answers this needs first

  • Is parity the goal for every style, or do the illustrated styles get a declared budget?
  • If no clip sample arrives, do the affected textures come out of the canvas?
  • Does Outline Width become a multiplier on the artwork's own widths, or stay an override? Gears asks for 1.5 px, which is 0.125 mm, and gets 0.2 mm — every line 1.6× heavier than drawn. Coins asks for 181 distinct widths, Buttons 157, Gems 104, and all of them get the one.
  • Hard offset shadow, or no shadow anywhere?

Back to XCS-only rendering

blocking

The rule. Work only in real XCS project mode. If a pattern cannot be seen as XCS shapes, it does not exist. The point is to stop a gap opening between the patterns we believe we have and the ones that actually run on the laser. one render, and it is the file the machine gets

How the rule was lost

Every tab in the tool renders through XCSViewer and nothing else. Fifteen of the sixteen still do. Shape Fill is the single exception: it carries a renderTarget of html or xtool, and it defaults to html — so the first thing anyone sees is the mode that is not real.

The file's own header says where that came from: ported unchanged from laser-experiments/laser-fill-generator. The canvas arrived as baggage with a port of a standalone canvas app, which still exists alongside the tool. The rule was not argued away, it was carried around, and then fourteen styles were built and tuned against the canvas rather than against the file.

The cost is the chasm the rule existed to prevent. The two plans above are the measurements of it: a style can look finished on the canvas while dropping 29% of its drawing, collapsing 579 line weights into one, and asking for 51 laser layers.

How far each style is from surviving without the canvas

Counting what the canvas asks for that the XCS project cannot yet carry — clipped drawing, text, translucency, distinct line widths, and layers generated outside the palette:

StyleClippedTextTranslucentLine widthsGenerated layersTotal
Jigsaw000234
Gears000256
Flat000278
Rows of Ducks00011111
3D Basic00031214
Christmas Cookies0008714
Painted Lady00021516
Pipes and Gears00081118
3D Glossy00041619
Circuit Board031073673
Gems000225980
Buttons150432126104
World Coins06202250133
Autumn Leaves56700579331,178

Nine styles are within reach on the strength of the colour plan alone. Five are not, and Autumn Leaves is an order of magnitude past the rest.

The change, in two halves

Half one: raise the XCS project to the level the styles need.

  1. Colour and layer management, so a style's colours are powers.
  2. Per-shape stroke width, so line weight survives.
  3. Text baking through the recorder.
  4. Clip and alpha resolved — by a sample from Creative Space, or by a recorded decision that they are out of scope.

Half two: bring each style onto it, one at a time.

  1. Take the style in the order above, cheapest first.
  2. Render it both ways and diff the rasters.
  3. Close the difference — by teaching the exporter, or by changing the drawing so it only asks for what the file can carry.
  4. Agree its tolerance and write it down. Not every style needs zero; a style needs a number it meets.
  5. Mark it migrated. Once a style is migrated it is developed against XCS output only.

Then remove the split.

  1. Flip renderTarget to default to xtool — a one-line change that can land early and immediately stops new work being done against the canvas.
  2. When every style is migrated, delete the toggle, the second viewer, and updateShapeFillViewerVisibility.
  3. The canvas stays, but only as the recorder's input. It stops being something anyone looks at.

What could go wrong

  • A style may not survive the move. Autumn Leaves at 1,178 may cost more than it is worth. Retiring a style is an allowed outcome — a style that only exists on a canvas is exactly what the rule was written to prevent.
  • The canvas is faster. The XCS canvas draws SVG shapes and Gears emits 4,434 of them. Interactive slider dragging may become unusable before the shape-count guard is in. That guard stops being a nicety.
  • Editing the shape needs a canvas. Edit Shape drags handles on the canvas element. That interaction has to survive the removal of the canvas as a view, or move onto the XCS canvas.
  • Flipping the default surfaces everything at once. Fourteen styles will look wrong on first open. Worth doing early anyway, because that is the honest picture.
  • Tolerances can be set to hide the problem. A generous number makes a style pass without fixing it. Tolerances belong in review, with the difference image attached.

Test and validation

  1. The rule as a test. No component outside xcs-tab.js may hold a second render path. Assert that renderTarget, or anything like it, appears in no component — a grep-level check that keeps the rule from being lost a second time.
  2. Raster diff per style, canvas against XCS project, with the difference image kept and the per-style tolerance recorded beside it.
  3. The distance table as a burn-down. Re-run the counts above after each step of half one. Every number should fall; any that rises is a regression with a name.
  4. Shape count and file size per style, tracked in the same run, since closing gaps adds geometry.
  5. On the machine. The migration is not finished on a raster diff. Each migrated style goes through the manual test loop at least once — that is the whole point of the rule.

Answers this needs first

  • Flip the default to xtool now, before the work, or at the end of it?
  • Is retiring a style an acceptable outcome, or must all fourteen migrate?
  • Where does Edit Shape live once the canvas is not a view?

The documents

xcsformat.md

pattern-tool/ · 207 lines · locked 2026-03-18 · the source of truth

The format itself, section by section: node hierarchy, shape-to-parameter mapping, the mandatory processing node structure, text objects and glyph baking, layer sequencing and pathPlanning, integrity checks, exporter rules, the reference .xcs files, anchoring and rotation facts, and the relative geometry rule. Named by GEMINI.md as the authority; nothing goes in the exporter that is not verified here.

XCSViewer.md

pattern-tool/ · 73 lines · locked 2026-03-18 · how the tool represents XCS

Internal representation against the formal format, the core model in xcs-system.js, the parser in xcs-tab.js, and the coordinate and anchor maths for centring and −90° rotation. Carries the no-speculative-implementation protocol.

GEMINI.md

pattern-tool/ · 36 lines · mandates, absolute precedence

Hardware constraints, architecture map, the UX normalisation mandates every pattern panel follows, and the maintenance rules. Read it before touching anything in pattern-tool/.

manual-test-loop.md

xcs-module/ · 61 lines · verification procedure

How to check generated output against the official xTool XCS Studio software, rather than against our own viewer. The only way to confirm a structural change actually opens on the machine.

exportpatches.md

pattern-tool/ · 10 lines · what was tried and what failed

Six export iterations against the F2, each with the change made and the result. Iterations 1 to 5 all failed — missing settings UI, then no device selected on open, then the F2 entry still missing after switching to 36-character GUIDs and adding all four operation nodes. Iteration 6 added the root metadata extId, extName, version and minRequiredVersion, and is still marked pending test.

Tools

๐Ÿงช

XCS Module Validation

Unit and functional tests over the core module — project generation, baked glyphs, coordinate anchoring.

xcs-module/index.html
๐Ÿ“

XCS Reference Test

Generates the reference pattern to export and open in Creative Space, for checking geometry lands where the viewer says.

Pattern Tool · Material & Technical
๐ŸŽ›๏ธ

Pattern Tool

Open XCS parses an existing .xcs file into the internal representation; any pattern tab exports one.

pattern-tool/index.html