mirror of
https://github.com/pewdiepie-archdaemon/odysseus.git
synced 2026-10-16 20:02:20 +02:00
feat(photo-editor): Ajax Image Editor client (Photoshop-parity tools)
Photoshop-parity editor client: layers, selection/masking, brushes,
smart filters, LUT import, Quick Edit, mobile gestures, workspaces,
batch operations, and the service-worker versioning (v978->v1006).
Source: vendor/photocraft (ad1cff44) photo-editor client files, merged
onto upstream/dev (a8c147b2). Conflict resolution documented in
pr_work/A_conflicts.md; editor files taken from vendor/photocraft
(upstream's editor is an earlier snapshot of the same feature).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
c74f475679
commit
5fac693c1c
@@ -20,7 +20,7 @@ def run_node(body: str):
|
||||
return json.loads(result.stdout)
|
||||
|
||||
|
||||
def test_sampler_emits_even_spacing_and_flushes_endpoint():
|
||||
def test_sampler_emits_even_spacing_without_a_lone_endpoint_dab():
|
||||
data = run_node(
|
||||
f"""
|
||||
import {{ createBrushSampler }} from {json.dumps(ENGINE)};
|
||||
@@ -37,8 +37,31 @@ def test_sampler_emits_even_spacing_and_flushes_endpoint():
|
||||
console.log(JSON.stringify(samples));
|
||||
"""
|
||||
)
|
||||
assert [round(item["x"]) for item in data] == [0, 5, 10, 15, 20, 23]
|
||||
assert data[-1]["pressure"] == 0.25
|
||||
# The final point is within one spacing of the last dab, so it must not be
|
||||
# stamped as a detached dot; the last dab stays on the even-spacing grid.
|
||||
assert [round(item["x"]) for item in data] == [0, 5, 10, 15, 20]
|
||||
|
||||
|
||||
def test_sampler_fills_a_long_final_gap_with_spaced_dabs():
|
||||
data = run_node(
|
||||
f"""
|
||||
import {{ createBrushSampler }} from {json.dumps(ENGINE)};
|
||||
const samples = [];
|
||||
const sampler = createBrushSampler({{
|
||||
getDiameter: () => 20,
|
||||
getSpacing: () => 0.25,
|
||||
getSmoothing: () => 0,
|
||||
emit: sample => samples.push(sample),
|
||||
}});
|
||||
sampler.begin({{x:0,y:0,pressure:1}});
|
||||
sampler.add({{x:20,y:0,pressure:1}});
|
||||
sampler.end({{x:37,y:0,pressure:1}});
|
||||
console.log(JSON.stringify(samples.map(sample => Math.round(sample.x))));
|
||||
"""
|
||||
)
|
||||
# A far pointer-up point is reached with evenly spaced dabs (step ~5px),
|
||||
# never a single 17px-away dab that would appear as a dot.
|
||||
assert data == [0, 5, 10, 15, 20, 25, 30, 35]
|
||||
|
||||
|
||||
def test_pressure_mapping_is_independent_and_mouse_falls_back_to_full():
|
||||
@@ -74,3 +97,79 @@ def test_custom_presets_roundtrip_without_mutating_built_ins():
|
||||
assert data["names"][-1] == "Texture"
|
||||
assert data["target"]["brushSize"] == 42
|
||||
assert data["target"]["brushBlendMode"] == "multiply"
|
||||
|
||||
|
||||
def test_pixel_brush_preset_roundtrips_pixel_flag():
|
||||
data = run_node(
|
||||
f"""
|
||||
import {{ loadBrushPresets, captureBrushPreset, applyBrushPreset }} from {json.dumps(PRESETS)};
|
||||
const loaded = loadBrushPresets({{ getItem: () => null, setItem: () => {{}} }});
|
||||
const pixel = loaded.find(item => item.id === 'pixel-1px');
|
||||
const target = {{}};
|
||||
applyBrushPreset(target, pixel);
|
||||
const captured = captureBrushPreset(
|
||||
{{brushSize:1,brushOpacity:100,brushFlow:100,brushSoftness:0,brushSpacing:1,brushSmoothing:0,brushBlendMode:'source-over',brushPixel:true}},
|
||||
'Px',
|
||||
);
|
||||
console.log(JSON.stringify({{
|
||||
names: loaded.map(item => item.name),
|
||||
pixelSize: pixel && pixel.size,
|
||||
appliedPixel: target.brushPixel,
|
||||
capturedPixel: captured.pixel,
|
||||
}}));
|
||||
"""
|
||||
)
|
||||
assert "Pixel" in data["names"]
|
||||
assert data["pixelSize"] == 1
|
||||
assert data["appliedPixel"] is True
|
||||
assert data["capturedPixel"] is True
|
||||
|
||||
|
||||
def test_stroke_pipeline_has_pixel_stamping():
|
||||
source = (ROOT / "static/js/editor/stroke-pipeline.js").read_text()
|
||||
assert "function pixelDab(" in source
|
||||
assert "function pixelLine(" in source
|
||||
assert "state.brushPixel" in source
|
||||
assert "let pixelLast = null;" in source
|
||||
# Size-aware square stamping (integer, no anti-aliasing), snapped to a
|
||||
# grid whose cell is the brush size so consecutive dabs connect cleanly.
|
||||
assert "const pixelSize = Math.max(1, Math.round(diameter));" in source
|
||||
assert "const gx = Math.floor(x / s) * s;" in source
|
||||
assert "const gy = Math.floor(y / s) * s;" in source
|
||||
assert "ctx.fillRect(gx, gy, s, s);" in source
|
||||
# The connecting line walks whole grid cells, never half-cells.
|
||||
assert "let cx = Math.floor(Math.round(x0) / s);" in source
|
||||
assert "const cx1 = Math.floor(Math.round(x1) / s);" in source
|
||||
assert "ctx.fillRect(cx * s, cy * s, s, s);" in source
|
||||
state = (ROOT / "static/js/editor/state.js").read_text()
|
||||
assert "brushPixel: false" in state
|
||||
|
||||
|
||||
def test_pixel_brush_shows_a_size_grid():
|
||||
guides = (ROOT / "static/js/editor/precision-guides.js").read_text()
|
||||
assert "const pixelBrush = !!state.brushPixel && (state.tool === 'brush' || state.tool === 'eraser');" in guides
|
||||
assert "if (state.gridVisible || pixelBrush) {" in guides
|
||||
assert "Math.max(1, Math.round(Number(state.brushSize) || 1))" in guides
|
||||
assert "const multiplier = pixelBrush ? 1 :" in guides
|
||||
|
||||
|
||||
def test_pixel_brush_snaps_only_while_shift_is_held():
|
||||
source = (ROOT / "static/js/editor/stroke-pipeline.js").read_text()
|
||||
# Direction snap helper stays.
|
||||
assert "function snapDirection(dx, dy)" in source
|
||||
assert "Math.round(Math.atan2(dy, dx) / step) * step" in source
|
||||
assert "let pixelAnchor = null;" in source
|
||||
# A previous stroke's end is remembered so Shift+click connects from it.
|
||||
assert "let pixelClickAnchor = null;" in source
|
||||
assert "pixelAnchor = (sample && sample.shift && pixelClickAnchor)" in source
|
||||
assert "if (pixelLast) pixelClickAnchor = { x: pixelLast.x, y: pixelLast.y };" in source
|
||||
# Shift held => snap to the nearest axis/diagonal.
|
||||
assert "if (sample.shift) {" in source
|
||||
assert "const dir = snapDirection(ddx, ddy);" in source
|
||||
assert "pixelLine(target.ctx, pixelAnchor.x, pixelAnchor.y, end.x, end.y," in source
|
||||
# Freehand by default (no shift).
|
||||
assert "if (pixelLast) pixelLine(target.ctx, pixelLast.x, pixelLast.y, px, py," in source
|
||||
# The shift modifier has to reach the pipeline from the DOM event.
|
||||
stroke = (ROOT / "static/js/editor/tools/stroke.js").read_text()
|
||||
assert "shift: !!e?.shiftKey" in stroke
|
||||
|
||||
|
||||
Reference in New Issue
Block a user