ふんわりしたペイントツール「FluffyPainter」



画像


<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <title>Fluffy Painter 1.0</title>
    <script src="https://cdnjs.cloudflare.com/ajax/libs/paper.js/0.12.17/paper-full.min.js"></script>
    <style>
        :root { --bg-dark: #202225; --bg-panel: #2f3136; --bg-hover: #393c43; --text-main: #dcddde; --text-muted: #8e9297; --accent: #7289da; --border: #202225; }
        body { margin: 0; padding: 0; display: flex; flex-direction: column; height: 100vh; background-color: var(--bg-dark); color: var(--text-main); font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; font-size: 14px; overflow: hidden; }
        
        #menubar { display: flex; background-color: var(--bg-dark); padding: 5px 10px; border-bottom: 1px solid #111; user-select: none; }
        .menu-item { position: relative; padding: 5px 10px; cursor: pointer; border-radius: 3px; }
        .menu-item:hover { background-color: var(--bg-hover); }
        .dropdown { display: none; position: absolute; top: 100%; left: 0; background-color: var(--bg-panel); border: 1px solid #111; box-shadow: 0 4px 6px rgba(0,0,0,0.3); z-index: 100; min-width: 180px; }
        .menu-item:hover .dropdown { display: block; }
        .dropdown-item { padding: 8px 15px; cursor: pointer; display: flex; justify-content: space-between; }
        .dropdown-item:hover { background-color: var(--accent); color: white; }
        .shortcut-hint { color: var(--text-muted); font-size: 0.85em; }
        .dropdown-item:hover .shortcut-hint { color: #ddd; }

        #main { display: flex; flex-grow: 1; overflow: hidden; }

        #sidebar { width: 300px; background-color: var(--bg-panel); display: flex; flex-direction: column; border-right: 1px solid var(--border); }
        
        #tabs { display: flex; border-bottom: 1px solid var(--border); background-color: var(--bg-dark); }
        .tab-btn { flex: 1; padding: 10px 0; text-align: center; cursor: pointer; border-bottom: 2px solid transparent; color: var(--text-muted); }
        .tab-btn.active { color: var(--text-main); border-bottom-color: var(--accent); background-color: var(--bg-panel); }
        
        .tab-content { display: none; flex-grow: 1; padding: 15px; overflow-y: auto; }
        .tab-content.active { display: flex; flex-direction: column; gap: 15px; }

        .prop-group { display: flex; flex-direction: column; gap: 5px; background: rgba(0,0,0,0.1); padding: 10px; border-radius: 5px; border: 1px solid rgba(255,255,255,0.05); }
        .prop-group label { font-size: 0.9em; display: flex; justify-content: space-between; }
        input[type="range"] { width: 100%; accent-color: var(--accent); }
        input[type="color"] { width: 100%; height: 30px; border: none; border-radius: 4px; cursor: pointer; padding: 0; background: none; }
        
        button.action-btn { background-color: var(--accent); color: white; border: none; padding: 10px; border-radius: 4px; cursor: pointer; width: 100%; margin-top: 5px; font-weight: bold; }
        button.action-btn:hover { filter: brightness(1.2); }
        button.action-btn-green { background-color: #43b581; }
        button.action-btn-outline { background-color: transparent; border: 1px solid var(--text-muted); color: var(--text-main); }
        button.action-btn-outline:hover { background-color: rgba(255,255,255,0.1); }

        #workspace { flex-grow: 1; background-color: #1e1e1e; position: relative; display: flex; justify-content: center; align-items: center; overflow: hidden; }
        #checkerboard { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background-image: linear-gradient(45deg, #2a2a2a 25%, transparent 25%, transparent 75%, #2a2a2a 75%, #2a2a2a), linear-gradient(45deg, #2a2a2a 25%, transparent 25%, transparent 75%, #2a2a2a 75%, #2a2a2a); background-size: 20px 20px; background-position: 0 0, 10px 10px; z-index: 0; }
        canvas { background-color: transparent; z-index: 1; box-shadow: 0 0 20px rgba(0,0,0,0.8); }

        #status-bar { position: absolute; bottom: 10px; left: 10px; background: rgba(0,0,0,0.7); padding: 5px 10px; border-radius: 5px; z-index: 10; font-size: 0.9em; pointer-events: none;}
        
        .list-item { padding: 8px; border-radius: 4px; cursor: pointer; margin-bottom: 2px; border: 1px solid transparent; background-color: rgba(0,0,0,0.2); }
        .list-item:hover { background-color: rgba(255,255,255,0.05); }
        .list-item.selected { background-color: rgba(114, 137, 218, 0.4); border-color: var(--accent); font-weight: bold; color: white;}
    </style>
</head>
<body>

    <div id="menubar">
        <div class="menu-item">ファイル
            <div class="dropdown">
                <div class="dropdown-item" onclick="fileAction('new')">新規作成</div>
                <div class="dropdown-item" onclick="fileAction('open')">開く... (JSON)</div>
                <div class="dropdown-item" onclick="fileAction('save')">上書き保存 (JSON) <span class="shortcut-hint">Ctrl+S</span></div>
                <div class="dropdown-item" onclick="fileAction('png')">PNG出力</div>
                <div class="dropdown-item" onclick="fileAction('resize')">キャンバスサイズ変更</div>
            </div>
        </div>
        <div class="menu-item">編集
            <div class="dropdown">
                <div class="dropdown-item" onclick="execAction('addSpot')">スポットの追加 <span class="shortcut-hint">新規領域</span></div>
                <div class="dropdown-item" onclick="execAction('delete')">削除 <span class="shortcut-hint">Del</span></div>
                <div class="dropdown-item" onclick="execAction('copy')">コピー <span class="shortcut-hint">Ctrl+C</span></div>
                <div class="dropdown-item" onclick="execAction('paste')">ペースト <span class="shortcut-hint">Ctrl+V</span></div>
                <div class="dropdown-item" onclick="execAction('unite')">スポット結合 <span class="shortcut-hint">Ctrl+J</span></div>
            </div>
        </div>
        <div class="menu-item">操作
            <div class="dropdown">
                <div class="dropdown-item" onclick="setMode('select')">選択・移動</div>
                <div class="dropdown-item" onclick="setMode('draw')">ドロー (領域の追加)</div>
                <div class="dropdown-item" onclick="setMode('erase')">ドロー (領域を削る)</div>
                <div class="dropdown-item" onclick="setMode('adjust')">ベジェ曲線の調整</div>
            </div>
        </div>
        <div class="menu-item">表示
            <div class="dropdown">
                <div class="dropdown-item" onclick="changeZoom(1.2)">ズームイン <span class="shortcut-hint">PageUp</span></div>
                <div class="dropdown-item" onclick="changeZoom(0.8)">ズームアウト <span class="shortcut-hint">PageDown</span></div>
                <div class="dropdown-item" onclick="paper.view.zoom = 1">100%表示</div>
            </div>
        </div>
        <div class="menu-item">設定</div>
    </div>

    <div id="main">
        <div id="sidebar">
            <div id="tabs">
                <div class="tab-btn active" onclick="switchTab('tab-prop')">プロパティ</div>
                <div class="tab-btn" onclick="switchTab('tab-layers')">レイヤー</div>
                <div class="tab-btn" onclick="switchTab('tab-spots')">スポット</div>
            </div>

            <div id="tab-prop" class="tab-content active">
                <div class="prop-group">
                    <strong>現在のドロー設定</strong>
                    <label>ブラシ最大サイズ: <span id="valBrush">40</span>px</label>
                    <input type="range" id="uiBrushSize" min="10" max="150" value="40">
                </div>
                
                <hr style="border-color: #444; width: 100%; margin: 5px 0;">

                <div id="spot-properties" style="opacity: 0.5; pointer-events: none;">
                    <strong>選択中スポットの質感</strong>
                    <div class="prop-group">
                        <label>中心色 (ハイライト)</label>
                        <input type="color" id="uiCenterCol" value="#8ab87a">
                    </div>
                    <div class="prop-group">
                        <label>輪郭色 (影・エッジ)</label>
                        <input type="color" id="uiEdgeCol" value="#2d4a22">
                    </div>
                    <div class="prop-group">
                        <label>ふんわり感: <span id="valBlur">30</span></label>
                        <input type="range" id="uiBlur" min="5" max="80" value="30">
                    </div>
                    <div class="prop-group">
                        <label>影の深さ (きつさ): <span id="valDepth">15</span></label>
                        <input type="range" id="uiDepth" min="5" max="50" value="15">
                    </div>

                    <div style="display: flex; gap: 5px; margin-top: 10px;">
                        <button class="action-btn action-btn-outline" style="flex: 1; padding: 8px 5px; font-size: 0.9em;" onclick="execAction('vectorize')">■ ベクター化</button>
                        <button class="action-btn action-btn-green" style="flex: 1.5; padding: 8px 5px; margin-top: 0; font-size: 0.9em;" onclick="execAction('render')">▶ 3D化(立体化)</button>
                    </div>
                </div>
            </div>

            <div id="tab-layers" class="tab-content">
                <div class="list-item selected">レイヤー 1 (統合)</div>
            </div>
            <div id="tab-spots" class="tab-content">
                <p style="color:#888; font-size:12px; margin:0 0 10px 0;">※編集メニューから追加してください</p>
                <div id="spot-list-container"></div>
            </div>
        </div>

        <div id="workspace">
            <div id="checkerboard"></div>
            <canvas id="myCanvas" width="1000" height="800"></canvas>
            <div id="status-bar">モード: <span id="mode-text" style="color:var(--accent); font-weight:bold;">選択・移動</span> | ズーム: <span id="zoom-text">100%</span> | スクロール: 方向キー</div>
        </div>
    </div>

    <input type="file" id="fileLoader" style="display:none;" accept=".json">

    <script>
        paper.setup('myCanvas');
        
        let mode = 'select'; 
        let brushRadius = 40;
        let selectedSpots = [];
        let clipboard = null;
        let selectionRect = null;
        let spotCounter = 0;
        let draftQueue = [];

        const spotLayer = new paper.Layer({ name: 'spotLayer' }); 
        const draftLayer = new paper.Layer({ name: 'draftLayer' }); 
        const uiLayer = new paper.Layer({ name: 'uiLayer' }); 

        const uiBrushSize = document.getElementById('uiBrushSize');
        const uiCenterCol = document.getElementById('uiCenterCol');
        const uiEdgeCol = document.getElementById('uiEdgeCol');
        const uiBlur = document.getElementById('uiBlur');
        const uiDepth = document.getElementById('uiDepth');
        const propPanel = document.getElementById('spot-properties');
        const spotListContainer = document.getElementById('spot-list-container');

        uiBrushSize.oninput = (e) => { brushRadius = parseInt(e.target.value); document.getElementById('valBrush').innerText = brushRadius; };

        // プロパティ変更処理(エラー修正済)
        const updateProps = (e) => {
            if (e && e.target) {
                const valElem = document.getElementById(e.target.id.replace('ui', 'val'));
                if (valElem) valElem.innerText = e.target.value;
            }
            if (selectedSpots.length > 0) {
                selectedSpots.forEach(spot => {
                    spot.data.centerCol = uiCenterCol.value; 
                    spot.data.edgeCol = uiEdgeCol.value;
                    spot.data.blurAmt = parseInt(uiBlur.value); 
                    spot.data.depthAmt = parseInt(uiDepth.value);
                    if(spot.data.isRendered) renderFluffySpot(spot);
                });
            }
        };
        uiCenterCol.oninput = updateProps; uiEdgeCol.oninput = updateProps; uiBlur.oninput = updateProps; uiDepth.oninput = updateProps;

        window.switchTab = function(tabId) {
            document.querySelectorAll('.tab-btn').forEach(btn => btn.classList.remove('active'));
            document.querySelectorAll('.tab-content').forEach(content => content.classList.remove('active'));
            event.target.classList.add('active');
            document.getElementById(tabId).classList.add('active');
        };

        function commitVector() {
            if (draftQueue.length === 0 || selectedSpots.length === 0) return;
            
            let targetSpot = selectedSpots[0];
            let basePath = targetSpot.children['basePath'];
            if (!basePath) return;

            spotLayer.activate();
            let newBasePath = basePath.clone();
            newBasePath.visible = true;
            
            for (let item of draftQueue) {
                if (item.mode === 'draw') {
                    let temp = newBasePath.isEmpty() ? item.path.clone() : newBasePath.unite(item.path);
                    newBasePath.remove(); newBasePath = temp;
                } else if (item.mode === 'erase' && !newBasePath.isEmpty()) {
                    let temp = newBasePath.subtract(item.path);
                    newBasePath.remove(); newBasePath = temp;
                }
                item.path.remove(); 
            }
            draftQueue = []; 
            
            targetSpot.children['basePath'].remove();
            newBasePath.name = 'basePath';
            targetSpot.addChild(newBasePath);
            
            if (targetSpot.data.isRendered) renderFluffySpot(targetSpot);
            updateVisuals();
        }

        // 輪郭線を完全撤廃したUI更新
        function updateVisuals() {
            spotListContainer.innerHTML = '';
            let spots = spotLayer.children.filter(i => i.name && i.name.startsWith('Spot_'));
            for (let i = spots.length - 1; i >= 0; i--) {
                let spot = spots[i];
                let div = document.createElement('div');
                div.className = 'list-item' + (selectedSpots.includes(spot) ? ' selected' : '');
                div.innerText = spot.data.displayName || spot.name;
                div.onclick = (e) => {
                    commitVector(); 
                    if (mode !== 'adjust' && mode !== 'draw' && mode !== 'erase') setMode('select');
                    selectSpot(spot, e.ctrlKey || e.metaKey);
                };
                spotListContainer.appendChild(div);
            }

            spotLayer.children.forEach(spot => {
                if (!spot.name || !spot.name.startsWith('Spot_')) return;
                let base = spot.children['basePath'];
                let renderGrp = spot.children['renderGroup'];

                if (mode === 'adjust' && selectedSpots.includes(spot)) {
                    base.visible = true; base.fullySelected = true;
                    base.strokeColor = '#00aaff'; base.strokeWidth = 1.5; base.fillColor = null;
                    if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.5; }
                } else if ((mode === 'draw' || mode === 'erase') && selectedSpots.includes(spot)) {
                    // ドロー中は輪郭線を消し、半透明の塗りのみで形を確認
                    base.visible = true; base.fullySelected = false;
                    base.strokeColor = null; base.strokeWidth = 0;
                    base.fillColor = 'rgba(255, 255, 255, 0.01)'; // 当たり判定用
                    if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.8; }
                } else {
                    base.visible = false; base.fullySelected = false;
                    base.strokeColor = null; base.strokeWidth = 0;
                    if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 1.0; }
                }

                if (!spot.data.isRendered) {
                    base.visible = true; base.fullySelected = false;
                    base.strokeColor = null; base.strokeWidth = 0;
                    base.fillColor = 'rgba(200, 200, 200, 0.5)'; // 未立体化時の仮の色
                    if (renderGrp) renderGrp.visible = false;
                }
            });

            if (mode === 'select' || mode === 'adjust') updateSelectionBounds();
            else if (selectionRect) { selectionRect.remove(); selectionRect = null; }
        }

        window.setMode = function(newMode) {
            commitVector(); 
            mode = newMode;
            const texts = { 'draw': 'ドロー (追加)', 'erase': 'ドロー (削る)', 'select': '選択・移動', 'adjust': 'ベジェ調整' };
            document.getElementById('mode-text').innerText = texts[mode];
            document.getElementById('myCanvas').style.cursor = mode === 'select' ? 'default' : 'crosshair';
            updateVisuals();
        };

        // --- 滑らかでふんわりとしたグラデーションシェーダー ---
        function renderFluffySpot(spotGroup) {
            const oldRender = spotGroup.children['renderGroup'];
            if (oldRender) oldRender.remove();

            spotGroup.data.isRendered = true; 
            const data = spotGroup.data;
            const basePath = spotGroup.children['basePath'];

            if (!basePath || basePath.isEmpty()) return;

            const renderGroup = new paper.Group({ name: 'renderGroup' });
            
            const baseFill = basePath.clone();
            baseFill.visible = true;
            baseFill.fillColor = data.centerCol;
            renderGroup.addChild(baseFill);

            const clipGroup = new paper.Group();
            const clipMask = basePath.clone();
            clipMask.visible = true;
            clipGroup.addChild(clipMask);
            clipGroup.clipped = true;

            const shiftOffset = 10000;

            // 広く薄い影:輪郭線を太くすることで、内側へ滑らかにグラデーションを届かせる
            const shadowWide = basePath.clone();
            shadowWide.visible = true;
            shadowWide.fillColor = null;
            shadowWide.strokeColor = data.edgeCol;
            shadowWide.strokeWidth = data.blurAmt || 1; 
            shadowWide.shadowColor = data.edgeCol;
            shadowWide.shadowBlur = data.blurAmt;
            shadowWide.translate(new paper.Point(shiftOffset, 0));
            shadowWide.shadowOffset = new paper.Point(-shiftOffset, 0);
            
            // 深い影:際を少し引き締める
            const shadowDeep = basePath.clone();
            shadowDeep.visible = true;
            shadowDeep.fillColor = null;
            shadowDeep.strokeColor = data.edgeCol;
            shadowDeep.strokeWidth = data.depthAmt || 1; 
            shadowDeep.shadowColor = data.edgeCol;
            shadowDeep.shadowBlur = data.depthAmt; 
            shadowDeep.translate(new paper.Point(shiftOffset, 0));
            shadowDeep.shadowOffset = new paper.Point(-shiftOffset, 0); 

            clipGroup.addChild(shadowWide);
            clipGroup.addChild(shadowDeep);
            renderGroup.addChild(clipGroup);
            spotGroup.addChild(renderGroup);
            
            if (selectedSpots.length === 1 && selectedSpots[0] === spotGroup) {
                uiCenterCol.value = data.centerCol; uiEdgeCol.value = data.edgeCol;
                uiBlur.value = data.blurAmt; uiDepth.value = data.depthAmt;
                document.getElementById('valBlur').innerText = data.blurAmt;
                document.getElementById('valDepth').innerText = data.depthAmt;
            }
        }

        // --- 目の形(テーパード)になるブラシストローク生成 ---
        function createTaperedPath(points, maxRadius) {
            if (points.length < 2) {
                return new paper.Path.Circle({ center: points[0] || new paper.Point(0,0), radius: maxRadius / 2 });
            }

            let spine = new paper.Path({ segments: points });
            spine.simplify(2); // 軌跡を滑らかに
            
            let totalLength = spine.length;
            if (totalLength < 1) {
                spine.remove();
                return new paper.Path.Circle({ center: points[0], radius: maxRadius / 2 });
            }

            let outline = new paper.Path();
            let samples = Math.max(10, Math.floor(totalLength / 3)); 
            let topPoints = [];
            let bottomPoints = [];

            for (let i = 0; i <= samples; i++) {
                let t = i / samples; // 0 から 1
                let offset = t * totalLength;
                let point = spine.getPointAt(offset);
                let normal = spine.getNormalAt(offset);
                if (!point || !normal) continue;

                // サイン波で始点と終点が細くなる「目(葉っぱ)」の形を計算
                let radius = Math.sin(t * Math.PI) * maxRadius;

                topPoints.push(point.add(normal.multiply(radius)));
                bottomPoints.unshift(point.subtract(normal.multiply(radius)));
            }

            outline.addSegments(topPoints);
            outline.addSegments(bottomPoints);
            outline.closed = true;
            outline.simplify(2);

            spine.remove();
            return outline;
        }

        const tool = new paper.Tool();
        let strokePoints = [];
        let currentDraftPath = null;
        let activeSegment = null; let activeHandle = null;

        tool.onMouseDown = function(event) {
            if (mode === 'select') {
                const hitResult = spotLayer.hitTest(event.point, { fill: true, stroke: true, tolerance: 2 });
                if (hitResult) {
                    let item = hitResult.item;
                    while(item.parent && item.parent !== spotLayer) { item = item.parent; } 
                    selectSpot(item, event.modifiers.control || event.modifiers.command);
                } else {
                    clearSelection();
                }
                return;
            }

            if (mode === 'adjust') {
                if (selectedSpots.length === 0) return;
                let targetSpot = selectedSpots[0];
                let hitResult = targetSpot.children['basePath'].hitTest(event.point, { segments: true, handles: true, tolerance: 8 });
                if (hitResult) {
                    if (hitResult.type === 'segment') activeSegment = hitResult.segment;
                    else if (hitResult.type === 'handle-in' || hitResult.type === 'handle-out') {
                        activeHandle = hitResult.type; activeSegment = hitResult.segment;
                    }
                }
                return;
            }

            if (selectedSpots.length === 0) {
                alert("ドローするスポットをリストから選択するか、「編集」メニューからスポットを追加してください。");
                setMode('select'); return;
            }

            draftLayer.activate();
            strokePoints = [event.point];
            currentDraftPath = new paper.Path.Circle({
                center: event.point, radius: 1, // 初期は点
                fillColor: mode === 'draw' ? 'rgba(200, 200, 200, 0.6)' : 'rgba(255, 68, 68, 0.6)'
            });
        };

        tool.onMouseDrag = function(event) {
            if (mode === 'select') {
                selectedSpots.forEach(spot => spot.position = spot.position.add(event.delta));
                updateSelectionBounds(); return;
            }

            if (mode === 'adjust' && activeSegment) {
                if (activeHandle === 'handle-in') activeSegment.handleIn = activeSegment.handleIn.add(event.delta);
                else if (activeHandle === 'handle-out') activeSegment.handleOut = activeSegment.handleOut.add(event.delta);
                else activeSegment.point = activeSegment.point.add(event.delta);
                return;
            }

            if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
                // 少し移動したら軌跡ポイントを追加
                if (event.point.getDistance(strokePoints[strokePoints.length - 1]) > 2) {
                    strokePoints.push(event.point);
                    currentDraftPath.remove();
                    
                    // 軌跡から目の形のストロークをリアルタイム生成
                    currentDraftPath = createTaperedPath(strokePoints, brushRadius);
                    currentDraftPath.fillColor = mode === 'draw' ? 'rgba(200, 200, 200, 0.6)' : 'rgba(255, 68, 68, 0.6)';
                }
            }
        };

        tool.onMouseUp = function(event) {
            if (mode === 'adjust' && activeSegment) {
                if (selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
                updateSelectionBounds(); activeSegment = null; activeHandle = null; return;
            }

            if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
                draftQueue.push({ path: currentDraftPath, mode: mode });
                currentDraftPath = null;
                strokePoints = [];
            }
        };

        function selectSpot(spot, isMulti) {
            commitVector(); 

            if (!isMulti) selectedSpots = [];
            if (!selectedSpots.includes(spot)) selectedSpots.push(spot);
            else if (isMulti) selectedSpots = selectedSpots.filter(s => s !== spot);

            propPanel.style.opacity = selectedSpots.length > 0 ? '1' : '0.5';
            propPanel.style.pointerEvents = selectedSpots.length > 0 ? 'auto' : 'none';
            updateVisuals(); 
        }

        function clearSelection() {
            commitVector();
            selectedSpots = [];
            propPanel.style.opacity = '0.5';
            propPanel.style.pointerEvents = 'none';
            updateVisuals();
        }

        function updateSelectionBounds() {
            if (selectionRect) { selectionRect.remove(); selectionRect = null; }
            if (selectedSpots.length === 0 || mode === 'adjust') return; 

            uiLayer.activate();
            let bounds = selectedSpots[0].bounds;
            for(let i=1; i<selectedSpots.length; i++) bounds = bounds.unite(selectedSpots[i].bounds);
            
            selectionRect = new paper.Path.Rectangle(bounds);
            selectionRect.strokeColor = '#7289da';
            selectionRect.strokeWidth = 2 / paper.view.zoom; 
            selectionRect.dashArray = [4, 4];
        }

        window.changeZoom = function(factor) {
            paper.view.zoom *= factor;
            document.getElementById('zoom-text').innerText = Math.round(paper.view.zoom * 100) + '%';
            if(selectionRect) updateSelectionBounds();
            updateVisuals();
        }

        window.execAction = function(action) {
            commitVector(); 

            if (action === 'addSpot') {
                spotCounter++;
                const spotGroup = new paper.Group({ name: 'Spot_' + Date.now() });
                spotGroup.data = { isRendered: false, displayName: 'スポット ' + spotCounter, centerCol: uiCenterCol.value, edgeCol: uiEdgeCol.value, blurAmt: parseInt(uiBlur.value), depthAmt: parseInt(uiDepth.value) };
                
                const basePath = new paper.Path({ name: 'basePath', visible: false });
                spotGroup.addChild(basePath);
                spotLayer.addChild(spotGroup);
                
                selectSpot(spotGroup, false);
                setMode('draw'); 
            }
            else if (action === 'vectorize') {
                updateVisuals(); 
            }
            else if (action === 'render') {
                if (selectedSpots.length > 0) {
                    selectedSpots.forEach(spot => renderFluffySpot(spot));
                    setMode('select'); 
                }
            }
            else if (action === 'delete') {
                selectedSpots.forEach(s => s.remove());
                clearSelection();
            } else if (action === 'copy' && selectedSpots.length > 0) {
                clipboard = selectedSpots[0].exportJSON();
            } else if (action === 'paste' && clipboard) {
                spotLayer.activate();
                let pasted = new paper.Group();
                pasted.importJSON(clipboard);
                pasted.name = 'Spot_' + Date.now();
                pasted.data.displayName = pasted.data.displayName + ' (コピー)';
                pasted.position.x += 20; pasted.position.y += 20; 
                spotLayer.addChild(pasted);
                if (pasted.data.isRendered) renderFluffySpot(pasted);
                selectSpot(pasted, false);
            } else if (action === 'unite' && selectedSpots.length > 1) {
                let combinedPath = selectedSpots[0].children['basePath'].clone();
                for (let i = 1; i < selectedSpots.length; i++) {
                    let path2 = selectedSpots[i].children['basePath'].clone();
                    if (!path2.isEmpty()) {
                        let temp = combinedPath.isEmpty() ? path2.clone() : combinedPath.unite(path2);
                        combinedPath.remove(); 
                        path2.remove();
                        combinedPath = temp;
                    } else {
                        path2.remove();
                    }
                }
                
                let newData = Object.assign({}, selectedSpots[0].data);
                selectedSpots.forEach(s => s.remove());
                clearSelection();
                
                spotCounter++;
                let newSpot = new paper.Group({ name: 'Spot_' + Date.now() });
                newSpot.data = newData;
                newSpot.data.displayName = '統合スポット ' + spotCounter;
                combinedPath.name = 'basePath';
                newSpot.addChild(combinedPath);
                
                if (newData.isRendered) renderFluffySpot(newSpot);
                spotLayer.addChild(newSpot);
                selectSpot(newSpot, false);
            }
        };

        window.fileAction = function(action) {
            commitVector();

            if (action === 'new') {
                if(confirm('現在の作業内容は失われます。新規作成しますか?')) {
                    spotLayer.removeChildren(); draftLayer.removeChildren();
                    spotCounter = 0; clearSelection();
                    execAction('addSpot');
                }
            } else if (action === 'save') {
                const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(spotLayer.exportJSON());
                const a = document.createElement('a');
                a.setAttribute("href", dataStr);
                a.setAttribute("download", "fluffy_project.json");
                a.click();
            } else if (action === 'open') {
                document.getElementById('fileLoader').click();
            } else if (action === 'png') {
                const oldMode = mode;
                setMode('select'); clearSelection(); 
                
                // PNG出力前:完全に不要な線を消去して出力
                spotLayer.children.forEach(spot => {
                    if (spot.name && spot.name.startsWith('Spot_')) {
                        if (!spot.data.isRendered) renderFluffySpot(spot);
                        let base = spot.children['basePath'];
                        if (base) {
                            base.visible = false;
                            base.strokeColor = null;
                            base.strokeWidth = 0;
                        }
                        let renderGrp = spot.children['renderGroup'];
                        if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 1.0; }
                    }
                });

                paper.view.update(); 
                
                setTimeout(() => {
                    const a = document.createElement('a');
                    a.href = document.getElementById('myCanvas').toDataURL('image/png');
                    a.download = 'fluffy_export.png';
                    a.click();
                    setMode(oldMode); 
                }, 100);
            } else if (action === 'resize') {
                const w = prompt('新しい幅を入力', paper.view.viewSize.width);
                const h = prompt('新しい高さを入力', paper.view.viewSize.height);
                if (w && h) { paper.view.viewSize = new paper.Size(parseInt(w), parseInt(h)); }
            }
        };

        document.getElementById('fileLoader').addEventListener('change', function(e) {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = function(evt) {
                spotLayer.removeChildren();
                spotLayer.importJSON(evt.target.result);
                spotLayer.children.forEach(spot => {
                    if(spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot);
                });
                clearSelection();
            };
            reader.readAsText(file);
            this.value = ''; 
        });

        document.addEventListener('keydown', (e) => {
            if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
                e.preventDefault();
                const panStep = 30 / paper.view.zoom;
                if (e.key === 'ArrowUp') paper.view.center.y -= panStep;
                if (e.key === 'ArrowDown') paper.view.center.y += panStep;
                if (e.key === 'ArrowLeft') paper.view.center.x -= panStep;
                if (e.key === 'ArrowRight') paper.view.center.x += panStep;
                if (selectionRect) updateSelectionBounds();
                return;
            }

            if (e.key === 'PageUp') { e.preventDefault(); changeZoom(1.2); }
            if (e.key === 'PageDown') { e.preventDefault(); changeZoom(1.1 / 1.2); }
            if (e.key === 'Delete') execAction('delete');

            if (e.ctrlKey || e.metaKey) {
                if (e.key.toLowerCase() === 'c') execAction('copy');
                if (e.key.toLowerCase() === 'v') execAction('paste');
                if (e.key.toLowerCase() === 'j') { e.preventDefault(); execAction('unite'); }
                if (e.key.toLowerCase() === 's') { e.preventDefault(); fileAction('save'); }
            }
        });
        
        execAction('addSpot');

    </script>
</body>
</html>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
ふんわりしたペイントツール「FluffyPainter」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1