描いたところが立体になる「SlimePainter」
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<title>Slime 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: #0d0f12; --bg-panel: #171a1f; --bg-hover: #1e2229; --text-main: #c8cdd5; --text-muted: #606878; --accent: #4fc3f7; --border: #0d0f12; }
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; touch-action: none;}
/* 共通UI */
#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; }
/* ツールバー */
#toolbar { display: flex; background-color: var(--bg-panel); padding: 5px 10px; border-bottom: 1px solid var(--border); gap: 5px; align-items: center; }
.tool-btn { background: transparent; border: 1px solid transparent; color: var(--text-main); font-size: 1.2em; padding: 5px 10px; border-radius: 4px; cursor: pointer; display: flex; align-items: center; gap: 5px; }
.tool-btn:hover { background-color: rgba(255,255,255,0.1); border-color: rgba(255,255,255,0.2); }
.tool-btn.active { background-color: var(--accent); color: white; border-color: var(--accent); }
.toolbar-sep { width: 1px; height: 24px; background-color: #555; margin: 0 10px; }
#main { display: flex; flex-grow: 1; overflow: hidden; }
/* サイドバーとタブ */
#sidebar { width: 320px; flex-shrink: 0; background-color: var(--bg-panel); display: flex; flex-direction: column; border-right: 1px solid var(--border); z-index: 20;}
#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); font-size: 0.9em; }
.tab-btn.active { color: var(--text-main); border-bottom-color: var(--accent); background-color: var(--bg-panel); font-weight: bold;}
.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); }
select { background-color: #222; color: #fff; border: 1px solid #555; padding: 4px; border-radius: 3px; outline: none;}
.color-picker-wrap { display: flex; align-items: center; gap: 10px; width: 100%; }
.color-drag-handle { width: 100%; height: 30px; border-radius: 4px; border: 1px solid #555; cursor: pointer; display: flex; justify-content: center; align-items: center; box-shadow: inset 0 0 4px rgba(0,0,0,0.3); transition: transform 0.1s;}
.color-drag-handle:hover { transform: scale(1.02); border-color: #aaa; }
#palette-grid { display: grid; grid-template-columns: repeat(4, 1fr); gap: 4px; margin-top: 5px; }
.palette-cell { height: 25px; border-radius: 3px; border: 1px solid #111; cursor: pointer; box-sizing: border-box; }
.palette-cell:hover { border-color: #fff; transform: scale(1.05); }
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: linear-gradient(135deg, #1a7fa0, #0a4f6a); }
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); }
/* リスト項目 */
.list-item { padding: 6px 8px; border-radius: 4px; cursor: pointer; margin-bottom: 4px; border: 1px solid transparent; background-color: rgba(0,0,0,0.2); display: flex; align-items: center; gap: 10px; }
.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); color: white;}
.list-item input[type="checkbox"] { cursor: pointer; width: 16px; height: 16px; accent-color: var(--accent); }
.list-item .item-name { flex-grow: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; pointer-events: none;}
/* キャンバス領域 */
#workspace { flex-grow: 1; background-color: #0a0c0f; position: relative; display: flex; overflow: auto; padding: 40px; box-sizing: border-box; align-items: flex-start; justify-content: flex-start; background-image: radial-gradient(circle at 30% 20%, #111820 0%, #0a0c0f 70%);}
#canvas-wrap { position: relative; box-shadow: 0 0 20px rgba(0,0,0,0.8); flex-shrink: 0; margin: auto; background-color: transparent;}
#checkerboard { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background-image: linear-gradient(45deg, #111318 25%, transparent 25%, transparent 75%, #111318 75%, #111318), linear-gradient(45deg, #111318 25%, transparent 25%, transparent 75%, #111318 75%, #111318); background-size: 20px 20px; background-position: 0 0, 10px 10px; z-index: 0; }
canvas { display: block; background-color: transparent; z-index: 1; position: absolute; top: 0; left: 0; touch-action: none;}
#status-bar { position: fixed; bottom: 10px; left: 340px; background: rgba(0,0,0,0.7); padding: 5px 10px; border-radius: 5px; z-index: 10; font-size: 0.9em; pointer-events: none;}
/* ポップアップ・ダイアログ */
.popup-menu { position: fixed; background-color: var(--bg-panel); border: 1px solid #111; box-shadow: 0 4px 6px rgba(0,0,0,0.5); z-index: 1000; border-radius: 4px; padding: 5px 0; display: none; min-width: 150px;}
.cm-item { padding: 8px 15px; cursor: pointer; font-size: 0.9em; }
.cm-item:hover { background-color: var(--accent); color: white; }
.cm-sep { height: 1px; background-color: #444; margin: 4px 0; }
#palette-dialog { display:none; position:fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.8); z-index:2000; justify-content:center; align-items:center; }
</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 style="color:#888;">Ctrl+S</span></div>
<div class="dropdown-item" onclick="fileAction('png')">PNG出力</div>
<div class="dropdown-item" onclick="fileAction('svg')">SVG出力</div>
<div class="cm-sep"></div>
<div class="dropdown-item" onclick="fileAction('obj')" style="color:var(--accent); font-weight:bold;">OBJ出力 (3Dモデル)</div>
<div class="cm-sep"></div>
<div class="dropdown-item" onclick="fileAction('resize')">キャンバスサイズ変更</div>
</div>
</div>
<div class="menu-item">表示
<div class="dropdown">
<div class="dropdown-item" onclick="changeZoom(1.2)">ズームイン <span style="color:#888;">PageUp</span></div>
<div class="dropdown-item" onclick="changeZoom(1 / 1.2)">ズームアウト <span style="color:#888;">PageDown</span></div>
<div class="dropdown-item" onclick="changeZoom(null)">100%表示</div>
</div>
</div>
<div class="menu-item">設定</div>
</div>
<div id="toolbar">
<button class="tool-btn active" onclick="setMode('draw')" id="btn-draw" title="スポット領域をドローしていく">🖊️ 描く</button>
<button class="tool-btn" onclick="setMode('erase')" id="btn-erase" title="スポット領域を消していく">🧽 消す</button>
<button class="tool-btn" onclick="setMode('fill')" id="btn-fill" title="囲まれた領域を塗りつぶす">🎨 塗る</button>
<div class="toolbar-sep"></div>
<button class="tool-btn" onclick="setMode('select')" id="btn-select" title="スポットを選択・複数選択">👆 選択</button>
<button class="tool-btn" onclick="setMode('move')" id="btn-move" title="ドラッグで移動">🖐️ 移動</button>
<button class="tool-btn" onclick="setMode('rotate')" id="btn-rotate" title="ドラッグで回転">🔄 回転</button>
<button class="tool-btn" onclick="setMode('scale')" id="btn-scale" title="ドラッグで拡縮">📐 拡縮</button>
<div class="toolbar-sep"></div>
<button class="tool-btn" onclick="setMode('adjust')" id="btn-adjust" title="ベジェ曲線で調整">✒️ 調整</button>
<div class="toolbar-sep"></div>
<button class="tool-btn" onclick="execAction('undo')" id="btn-undo" title="元に戻す (Ctrl+Z)">⏪️</button>
<button class="tool-btn" onclick="execAction('redo')" id="btn-redo" title="やり直す (Ctrl+Y)">⏩️</button>
</div>
<div id="main">
<div id="sidebar">
<div id="tabs">
<div class="tab-btn active" onclick="switchTab('tab-layers')">レイヤー</div>
<div class="tab-btn" onclick="switchTab('tab-spots')">スポット</div>
<div class="tab-btn" onclick="switchTab('tab-prop')">プロパティ</div>
</div>
<div id="tab-layers" class="tab-content active" style="position: relative;">
<p style="color:#888; font-size:12px; margin:0;">※右クリックでメニューを表示</p>
<div id="layer-list-container" style="flex-grow: 1;"></div>
</div>
<div id="tab-spots" class="tab-content" style="position: relative;">
<p style="color:#888; font-size:12px; margin:0;">※右クリックでメニューを表示</p>
<div id="spot-list-container" style="flex-grow: 1;"></div>
</div>
<div id="tab-prop" class="tab-content">
<div class="prop-group">
<strong>ドロー設定</strong>
<label>ブラシサイズ: <span id="valBrush">10</span>px</label>
<input type="range" id="uiBrushSize" min="1" max="50" value="10">
</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" style="background: rgba(79, 195, 247, 0.1); border-color: rgba(79, 195, 247, 0.3);">
<label style="color: var(--accent); font-weight: bold;">表面テクスチャ・模様</label>
<div style="display:flex; gap:5px; margin-top: 5px;">
<input type="file" id="uiTextureImage" accept="image/*" style="display:none;" onchange="handleTextureUpload(event)">
<button class="action-btn-outline" onclick="document.getElementById('uiTextureImage').click()" style="flex:1; padding:6px; font-size:0.9em;">📂 画像を選択</button>
<button class="action-btn-outline" onclick="clearTexture()" style="width:30px; padding:6px; font-size:0.9em;" title="画像をクリア">✖</button>
</div>
<img id="texturePreview" style="display:none; width:100%; height:auto; max-height: 80px; object-fit: contain; margin-top:5px; border-radius:4px; border:1px solid #555; background: #000;">
<label style="margin-top: 5px;">内蔵パターン:</label>
<select id="uiPattern" onchange="updateProps({target: this}); saveState();">
<option value="none">なし (単色/画像のみ)</option>
<option value="stripe">ストライプ</option>
<option value="checker">チェッカーボード</option>
<option value="dots">水玉模様</option>
<option value="noise">クラウドノイズ</option>
</select>
</div>
<div class="prop-group">
<label>反射質感タイプ:</label>
<select id="uiTexture" onchange="updateProps({target: this}); saveState();">
<option value="water">💧 水滴・スライム</option>
<option value="sticker">🏷️ モコモコシール</option>
<option value="chrome">🪞 クロムメタル</option>
<option value="gold">✨ ゴールド</option>
<option value="mercury">🌊 マーキュリー</option>
<option value="brushed">🔩 ブラッシュドメタル</option>
<option value="pearl">🫧 パール</option>
<option value="none">なし (フラット)</option>
</select>
</div>
<div class="prop-group">
<label>ハイライト色 / パターン色</label>
<div class="color-picker-wrap">
<input type="color" id="uiCenterCol" value="#ffffff" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
<div id="dragHandleCenter" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiCenterCol')" ondrop="handleColorDrop(event, 'uiCenterCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiCenterCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #ffffff;"></div>
</div>
</div>
<div class="prop-group">
<label>ベース色</label>
<div class="color-picker-wrap">
<input type="color" id="uiMidCol" value="#8ab4cc" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
<div id="dragHandleMid" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiMidCol')" ondrop="handleColorDrop(event, 'uiMidCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiMidCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #8ab4cc;"></div>
</div>
<button class="action-btn action-btn-outline" style="padding: 4px; font-size: 0.8em; margin-top: 2px;" onclick="generateMidColor()">中間色を自動生成</button>
</div>
<div class="prop-group">
<label>影・エッジ色</label>
<div class="color-picker-wrap">
<input type="color" id="uiEdgeCol" value="#1a2a3a" style="display:none;" oninput="updateProps({target: this})" onchange="saveState()">
<div id="dragHandleEdge" class="color-drag-handle" draggable="true" ondragstart="handleColorDragStart(event, 'uiEdgeCol')" ondrop="handleColorDrop(event, 'uiEdgeCol')" ondragover="event.preventDefault()" onclick="document.getElementById('uiEdgeCol').click()" title="クリックで色選択 / ドラッグ&ドロップで色を移動" style="background-color: #1a2a3a;"></div>
</div>
</div>
<div>
<div style="display:flex; justify-content:space-between; align-items:center;">
<label style="font-size: 0.8em; color: var(--text-muted);">グラデーションパレット</label>
<button class="action-btn-outline" style="padding:2px 5px; font-size:0.7em; margin:0; width:auto;" onclick="openPaletteLibrary()">もっと見る...</button>
</div>
<div id="palette-grid"></div>
</div>
<div class="prop-group" style="margin-top: 10px;">
<label>光の方向 (角度): <span id="valLightAngle">135</span>°</label>
<input type="range" id="uiLightAngle" min="0" max="360" value="135" oninput="updateProps({target: this})" onchange="saveState()">
</div>
<div class="prop-group">
<label>光の強さ: <span id="valLightIntensity">90</span>%</label>
<input type="range" id="uiLightIntensity" min="10" max="200" value="90" oninput="updateProps({target: this})" onchange="saveState()">
</div>
<div class="prop-group">
<label>丘の高さ (盛り上がり): <span id="valHillHeight">75</span></label>
<input type="range" id="uiHillHeight" min="5" max="100" value="75" oninput="updateProps({target: this})" onchange="saveState()">
</div>
<div class="prop-group">
<label>エッジの鋭さ: <span id="valDepth">12</span></label>
<input type="range" id="uiDepth" min="3" max="50" value="12" oninput="updateProps({target: this})" onchange="saveState()">
</div>
<div class="prop-group">
<label>反射の粗さ: <span id="valBlur">55</span></label>
<input type="range" id="uiBlur" min="1" max="80" value="55" oninput="updateProps({target: this})" onchange="saveState()">
</div>
<div style="display: flex; gap: 5px; margin-top: 10px;">
<button class="action-btn action-btn-outline" style="flex: 1; padding: 8px 5px;" onclick="execAction('vectorize')">■ ベクター化</button>
<button class="action-btn action-btn-green" style="flex: 1.5; padding: 8px 5px; margin-top: 0;" onclick="execAction('render')">💎 質感レンダー</button>
</div>
</div>
</div>
</div>
<div id="workspace" oncontextmenu="return false;">
<div id="canvas-wrap">
<div id="checkerboard"></div>
<canvas id="myCanvas"></canvas>
</div>
<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>
<div id="context-menu" class="popup-menu">
<div class="cm-item" data-action="add" onclick="cmAction('add')">追加</div>
<div class="cm-sep" data-action="sep1"></div>
<div class="cm-item" data-action="cut" onclick="cmAction('cut')">カット (Ctrl+X)</div>
<div class="cm-item" data-action="copy" onclick="cmAction('copy')">コピー (Ctrl+C)</div>
<div class="cm-item" data-action="paste" onclick="cmAction('paste')">貼り付け (Ctrl+V)</div>
<div class="cm-sep" data-action="sep2"></div>
<div class="cm-item" data-action="rename" onclick="cmAction('rename')">名前の変更</div>
<div class="cm-item" data-action="opacity" onclick="cmAction('opacity')">透明度の変更</div>
<div class="cm-sep" data-action="sep3"></div>
<div class="cm-item" data-action="merge" onclick="cmAction('merge')">結合 (選択項目のみ)</div>
<div class="cm-sep" data-action="sep_order"></div>
<div class="cm-item" data-action="bringFront" onclick="cmAction('bringFront')">最前面へ移動</div>
<div class="cm-item" data-action="bringForward" onclick="cmAction('bringForward')">前面へ移動</div>
<div class="cm-item" data-action="sendBackward" onclick="cmAction('sendBackward')">背面へ移動</div>
<div class="cm-item" data-action="sendBack" onclick="cmAction('sendBack')">最背面へ移動</div>
<div class="cm-sep" data-action="sep4"></div>
<div class="cm-item" data-action="delete" onclick="cmAction('delete')" style="color: #ff4444;">削除 (Del)</div>
</div>
<div id="adjust-menu" class="popup-menu">
<div class="cm-item" id="am-add" onclick="adjustMenuAction('add')">隣接するポイントの中間に追加</div>
<div class="cm-item" id="am-delete" onclick="adjustMenuAction('delete')" style="color: #ff4444;">選択中のポイントを削除</div>
</div>
<div id="palette-dialog">
<div style="background:var(--bg-panel); padding:20px; border-radius:8px; width:80%; max-height:80%; display:flex; flex-direction:column; border:1px solid #555;">
<div style="display:flex; justify-content:space-between; align-items:center; margin-bottom:15px;">
<h2 style="margin:0; font-size:1.2em;">パレットライブラリ</h2>
<button onclick="document.getElementById('palette-dialog').style.display='none'" class="action-btn-outline" style="padding:5px 10px; width:auto; margin:0;">閉じる</button>
</div>
<div style="color:var(--text-muted); font-size:0.9em; margin-bottom:10px;">クリックすると選択中のスポットのプロパティに適用されます。</div>
<div id="library-grid" style="display:grid; grid-template-columns:repeat(auto-fill, minmax(80px, 1fr)); gap:10px; overflow-y:auto; flex-grow:1; padding:5px;"></div>
</div>
</div>
<input type="file" id="fileLoader" style="display:none;" accept=".json">
<script>
paper.settings.handleSize = 8;
paper.setup('myCanvas');
let baseWidth = 1000;
let baseHeight = 800;
let currentZoom = 1;
let mode = 'draw';
let brushRadius = 10;
let appLayers = [];
let activeLayer = null;
let selectedLayers = [];
let layerCounter = 0;
let selectedSpots = [];
let spotCounter = 0;
let draftQueue = [];
let clipboardData = null;
let clipboardType = '';
let selectionRect = null;
let isPanning = false;
let cmTargetType = '';
let cmTargetItem = null;
let undoStack = [];
let redoStack = [];
let selectedSegments = [];
window.changeZoom = function(factor) {
let ws = document.getElementById('workspace');
let scrollCenterX = ws.scrollLeft + ws.clientWidth / 2;
let scrollCenterY = ws.scrollTop + ws.clientHeight / 2;
let oldZoom = currentZoom;
if (factor === null) { currentZoom = 1; }
else { currentZoom *= factor; }
let wrap = document.getElementById('canvas-wrap');
wrap.style.width = (baseWidth * currentZoom) + 'px';
wrap.style.height = (baseHeight * currentZoom) + 'px';
paper.view.viewSize = new paper.Size(baseWidth * currentZoom, baseHeight * currentZoom);
paper.view.zoom = currentZoom;
paper.view.center = new paper.Point(baseWidth / 2, baseHeight / 2);
if (factor !== null) {
let ratio = currentZoom / oldZoom;
ws.scrollLeft = scrollCenterX * ratio - ws.clientWidth / 2;
ws.scrollTop = scrollCenterY * ratio - ws.clientHeight / 2;
} else {
ws.scrollLeft = (wrap.offsetWidth - ws.clientWidth) / 2 + 40;
ws.scrollTop = (wrap.offsetHeight - ws.clientHeight) / 2 + 40;
}
document.getElementById('zoom-text').innerText = Math.round(currentZoom * 100) + '%';
if(selectionRect) updateSelectionBounds();
updateVisuals();
}
const workspaceElem = document.getElementById('workspace');
let initialPinchDist = null;
let initialZoom = null;
let lastTouchPoint = null;
workspaceElem.addEventListener('touchstart', (e) => {
if (e.touches.length === 2) {
e.preventDefault();
initialPinchDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
initialZoom = currentZoom;
} else if (e.touches.length === 1 && ['select','move','rotate','scale'].includes(mode) && !paper.project.hitTest(new paper.Point(e.touches[0].clientX - workspaceElem.getBoundingClientRect().left + workspaceElem.scrollLeft, e.touches[0].clientY - workspaceElem.getBoundingClientRect().top + workspaceElem.scrollTop), {fill:true, stroke:true, tolerance:4})) {
lastTouchPoint = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
}, {passive: false});
workspaceElem.addEventListener('touchmove', (e) => {
if (e.touches.length === 2 && initialPinchDist) {
e.preventDefault();
let currentDist = Math.hypot(e.touches[0].clientX - e.touches[1].clientX, e.touches[0].clientY - e.touches[1].clientY);
let ratio = currentDist / initialPinchDist;
let newZoom = initialZoom * ratio;
if (newZoom < 0.1) newZoom = 0.1;
if (newZoom > 10) newZoom = 10;
let factor = newZoom / currentZoom;
changeZoom(factor);
} else if (e.touches.length === 1 && lastTouchPoint) {
e.preventDefault();
workspaceElem.scrollLeft -= (e.touches[0].clientX - lastTouchPoint.x);
workspaceElem.scrollTop -= (e.touches[0].clientY - lastTouchPoint.y);
lastTouchPoint = { x: e.touches[0].clientX, y: e.touches[0].clientY };
}
}, {passive: false});
workspaceElem.addEventListener('touchend', (e) => {
if (e.touches.length < 2) initialPinchDist = null;
if (e.touches.length === 0) lastTouchPoint = null;
});
function saveState() {
let state = { layers: appLayers.map(l => l.exportJSON()), layerCounter: layerCounter, spotCounter: spotCounter };
undoStack.push(state);
if(undoStack.length > 30) undoStack.shift();
redoStack = [];
}
function restoreState(stateJSON) {
appLayers.forEach(l => l.remove()); appLayers = []; draftLayer.removeChildren();
layerCounter = stateJSON.layerCounter; spotCounter = stateJSON.spotCounter;
stateJSON.layers.forEach(lData => {
let newL = new paper.Layer(); newL.importJSON(lData); appLayers.push(newL);
newL.children.forEach(spot => { if(spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot); });
});
if(appLayers.length > 0) { activeLayer = appLayers[0]; selectedLayers = [activeLayer]; }
else { activeLayer = null; selectedLayers = []; }
selectedSpots = []; activeSegment = null; selectedSegments = [];
clearSelection();
}
let gradientPalette = JSON.parse(localStorage.getItem('liquid_metal_palette_v1')) || [
{c: '#ffffff', m: '#8ab4cc', e: '#0d2233'}, {c: '#ffffff', m: '#b0b8c0', e: '#1a1a1a'},
{c: '#fff7d0', m: '#d4a820', e: '#5a3800'}, {c: '#e0f0ff', m: '#6090c0', e: '#081828'},
{c: '#e8eaec', m: '#8090a0', e: '#1a2028'}, {c: '#fff0f8', m: '#d4a8c0', e: '#4a1030'},
{c: '#f0fff4', m: '#60c080', e: '#0a2810'}, {c: '#f8f0ff', m: '#9060c0', e: '#200840'},
];
const extendedPalettes = [
{c: '#ffffff', m: '#8ab4cc', e: '#0d2233'}, {c: '#ffffff', m: '#b0b8c0', e: '#1a1a1a'},
{c: '#fff7d0', m: '#d4a820', e: '#5a3800'}, {c: '#e0f0ff', m: '#6090c0', e: '#081828'},
{c: '#e8eaec', m: '#8090a0', e: '#1a2028'}, {c: '#fff0f8', m: '#d4a8c0', e: '#4a1030'},
{c: '#f0fff4', m: '#60c080', e: '#0a2810'}, {c: '#f8f0ff', m: '#9060c0', e: '#200840'},
{c: '#ffe8e8', m: '#c04040', e: '#280000'}, {c: '#fff4e0', m: '#c08020', e: '#381800'},
{c: '#e8f8ff', m: '#2080c0', e: '#001828'}, {c: '#f0ffe8', m: '#60a040', e: '#102000'},
{c: '#f8f8f8', m: '#404040', e: '#080808'}, {c: '#ffffe0', m: '#e0d000', e: '#303000'},
{c: '#ffe0f8', m: '#c040a0', e: '#280020'}, {c: '#e0fff8', m: '#20b090', e: '#003828'},
];
window.openPaletteLibrary = function() {
const grid = document.getElementById('library-grid');
grid.innerHTML = '';
extendedPalettes.forEach(colors => {
let cell = document.createElement('div');
cell.style.height = '40px'; cell.style.borderRadius = '4px'; cell.style.border = '1px solid #111'; cell.style.cursor = 'pointer';
cell.style.background = `linear-gradient(to right, ${colors.c} 33%, ${colors.m} 33% 66%, ${colors.e} 66%)`;
cell.onclick = () => {
uiCenterCol.value = colors.c; uiMidCol.value = colors.m; uiEdgeCol.value = colors.e;
updateProps({target: uiCenterCol}); updateProps({target: uiMidCol}); updateProps({target: uiEdgeCol});
saveState(); document.getElementById('palette-dialog').style.display='none';
};
grid.appendChild(cell);
});
document.getElementById('palette-dialog').style.display='flex';
};
function renderPalette() {
const grid = document.getElementById('palette-grid');
grid.innerHTML = '';
gradientPalette.forEach((colors, i) => {
let cell = document.createElement('div');
cell.className = 'palette-cell';
cell.style.background = `linear-gradient(to right, ${colors.c} 33%, ${colors.m} 33% 66%, ${colors.e} 66%)`;
cell.draggable = true;
cell.title = "ドラッグ&ドロップで3色セットを適用・保存";
cell.ondragstart = (e) => { e.dataTransfer.setData('application/json', JSON.stringify(colors)); };
cell.ondragover = (e) => e.preventDefault();
cell.ondrop = (e) => {
e.preventDefault(); let jsonData = e.dataTransfer.getData('application/json');
if (jsonData) {
try {
let parsed = JSON.parse(jsonData);
if (parsed.c && parsed.m && parsed.e) {
gradientPalette[i] = parsed; localStorage.setItem('liquid_metal_palette_v1', JSON.stringify(gradientPalette));
renderPalette();
}
} catch(err) {}
}
};
grid.appendChild(cell);
});
}
renderPalette();
window.handleColorDragStart = function(e, inputId) {
e.dataTransfer.setData('text/plain', document.getElementById(inputId).value);
e.dataTransfer.setData('source_id', 'current_colors');
let colors = { c: uiCenterCol.value, m: uiMidCol.value, e: uiEdgeCol.value };
e.dataTransfer.setData('application/json', JSON.stringify(colors));
}
window.handleColorDrop = function(e, inputId) {
e.preventDefault(); let jsonData = e.dataTransfer.getData('application/json');
if (jsonData) {
try {
let parsed = JSON.parse(jsonData);
if (parsed.c && parsed.m && parsed.e) {
uiCenterCol.value = parsed.c; uiMidCol.value = parsed.m; uiEdgeCol.value = parsed.e;
updateProps({target: uiCenterCol}); updateProps({target: uiMidCol}); updateProps({target: uiEdgeCol}); saveState(); return;
}
} catch(err) {}
}
let color = e.dataTransfer.getData('text/plain');
if (color && color.startsWith('#')) {
let input = document.getElementById(inputId); input.value = color;
updateProps({target: input}); saveState();
}
};
window.handleTextureUpload = function(e) {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = function(evt) {
const img = new Image();
img.onload = function() {
const maxS = 512;
let w = img.width, h = img.height;
if(w > maxS || h > maxS) {
const ratio = Math.min(maxS/w, maxS/h);
w *= ratio; h *= ratio;
}
const c = document.createElement('canvas');
c.width = w; c.height = h;
c.getContext('2d').drawImage(img, 0, 0, w, h);
const dataUrl = c.toDataURL('image/jpeg', 0.85);
document.getElementById('texturePreview').src = dataUrl;
document.getElementById('texturePreview').style.display = 'block';
const texImg = new Image();
texImg.onload = function() {
if (selectedSpots.length > 0) {
selectedSpots.forEach(spot => {
spot.data.customTexture = dataUrl;
spot.data.customTextureObj = texImg;
if(spot.data.isRendered) renderFluffySpot(spot);
});
saveState();
}
};
texImg.src = dataUrl;
};
img.src = evt.target.result;
};
reader.readAsDataURL(file);
e.target.value = '';
};
window.clearTexture = function() {
document.getElementById('texturePreview').style.display = 'none';
document.getElementById('texturePreview').src = '';
if (selectedSpots.length > 0) {
selectedSpots.forEach(spot => {
spot.data.customTexture = null;
spot.data.customTextureObj = null;
if(spot.data.isRendered) renderFluffySpot(spot);
});
saveState();
}
};
const draftLayer = new paper.Layer({ name: 'draftLayer' });
const uiLayer = new paper.Layer({ name: 'uiLayer' });
let adjustMarkers = new paper.Group({name: 'adjustMarkers'}); uiLayer.addChild(adjustMarkers);
function createNewLayer() {
layerCounter++; let newLayer = new paper.Layer({ name: 'Layer_' + Date.now() });
newLayer.data = { displayName: 'レイヤー ' + layerCounter, isVisible: true };
appLayers.unshift(newLayer); draftLayer.bringToFront(); uiLayer.bringToFront(); return newLayer;
}
activeLayer = createNewLayer(); selectedLayers = [activeLayer]; saveState();
const uiBrushSize = document.getElementById('uiBrushSize');
const uiCenterCol = document.getElementById('uiCenterCol');
const uiMidCol = document.getElementById('uiMidCol');
const uiEdgeCol = document.getElementById('uiEdgeCol');
const uiPattern = document.getElementById('uiPattern');
const uiBlur = document.getElementById('uiBlur');
const uiDepth = document.getElementById('uiDepth');
const uiLightAngle = document.getElementById('uiLightAngle');
const uiLightIntensity = document.getElementById('uiLightIntensity');
const uiHillHeight = document.getElementById('uiHillHeight');
const uiTexture = document.getElementById('uiTexture');
const propPanel = document.getElementById('spot-properties');
uiBrushSize.oninput = (e) => { brushRadius = parseInt(e.target.value); document.getElementById('valBrush').innerText = brushRadius; };
window.generateMidColor = function() {
let c = uiCenterCol.value; let e = uiEdgeCol.value;
let r = Math.round((parseInt(c.substring(1,3), 16) + parseInt(e.substring(1,3), 16)) / 2).toString(16).padStart(2, '0');
let g = Math.round((parseInt(c.substring(3,5), 16) + parseInt(e.substring(3,5), 16)) / 2).toString(16).padStart(2, '0');
let b = Math.round((parseInt(c.substring(5,7), 16) + parseInt(e.substring(5,7), 16)) / 2).toString(16).padStart(2, '0');
uiMidCol.value = '#' + r + g + b; updateProps({target: uiMidCol}); saveState();
};
window.updateProps = (e) => {
if (e && e.target) {
const valElem = document.getElementById(e.target.id.replace('ui', 'val'));
if (valElem) valElem.innerText = e.target.value;
if(e.target.type === 'color') {
if(e.target.id === 'uiCenterCol') document.getElementById('dragHandleCenter').style.backgroundColor = e.target.value;
if(e.target.id === 'uiMidCol') document.getElementById('dragHandleMid').style.backgroundColor = e.target.value;
if(e.target.id === 'uiEdgeCol') document.getElementById('dragHandleEdge').style.backgroundColor = e.target.value;
}
}
if (selectedSpots.length > 0) {
selectedSpots.forEach(spot => {
spot.data.centerCol = uiCenterCol.value; spot.data.midCol = uiMidCol.value; spot.data.edgeCol = uiEdgeCol.value;
spot.data.patternType = uiPattern.value || 'none';
spot.data.blurAmt = parseInt(uiBlur.value); spot.data.depthAmt = parseInt(uiDepth.value);
spot.data.lightAngle = parseInt(uiLightAngle.value) || 135;
spot.data.lightIntensity = parseInt(uiLightIntensity.value) || 90;
spot.data.hillHeight = parseInt(uiHillHeight.value) || 75;
spot.data.texture = uiTexture.value || 'water';
if(spot.data.isRendered) renderFluffySpot(spot);
});
}
};
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;
let parentLayer = targetSpot.parent; if(parentLayer) parentLayer.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(); saveState();
}
function getPathSegments(path) {
let segs = [];
if (path instanceof paper.CompoundPath) {
path.children.forEach(child => { if (child.segments) segs = segs.concat(child.segments); });
} else if (path.segments) { segs = path.segments; }
return segs;
}
function updateVisuals() {
const layerContainer = document.getElementById('layer-list-container');
layerContainer.innerHTML = '';
appLayers.forEach(layer => {
let div = document.createElement('div'); div.className = 'list-item' + (selectedLayers.includes(layer) ? ' selected' : '');
let chk = document.createElement('input'); chk.type = 'checkbox'; chk.checked = layer.data.isVisible !== false;
chk.onclick = (e) => { e.stopPropagation(); layer.visible = chk.checked; layer.data.isVisible = chk.checked; updateSelectionBounds(); };
let span = document.createElement('span'); span.className = 'item-name'; span.innerText = layer.data.displayName + (layer.opacity < 1 ? ` (${Math.round(layer.opacity*100)}%)` : '');
div.appendChild(chk); div.appendChild(span);
div.onclick = (e) => {
commitVector();
if(e.ctrlKey || e.metaKey) {
if(selectedLayers.includes(layer)) selectedLayers = selectedLayers.filter(l => l!==layer); else selectedLayers.push(layer);
} else selectedLayers = [layer];
activeLayer = selectedLayers.length > 0 ? selectedLayers[0] : appLayers[0];
clearSelection();
};
div.oncontextmenu = (e) => {
e.preventDefault(); e.stopPropagation();
if(!selectedLayers.includes(layer)) { selectedLayers = [layer]; activeLayer = layer; updateVisuals(); }
showContextMenu(e, 'layer', layer, false);
};
layerContainer.appendChild(div);
});
const spotContainer = document.getElementById('spot-list-container');
spotContainer.innerHTML = '';
if (activeLayer) {
let spots = activeLayer.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' : '');
let chk = document.createElement('input'); chk.type = 'checkbox'; chk.checked = spot.data.isVisible !== false;
chk.onclick = (e) => { e.stopPropagation(); spot.visible = chk.checked; spot.data.isVisible = chk.checked; updateSelectionBounds(); };
let span = document.createElement('span'); span.className = 'item-name'; span.innerText = spot.data.displayName + (spot.opacity < 1 ? ` (${Math.round(spot.opacity*100)}%)` : '');
div.appendChild(chk); div.appendChild(span);
div.onclick = (e) => {
commitVector();
if (['draw','erase','fill','move','rotate','scale','adjust'].indexOf(mode) === -1) setMode('select');
if(e.ctrlKey || e.metaKey) {
if(selectedSpots.includes(spot)) selectedSpots = selectedSpots.filter(s => s!==spot); else selectedSpots.push(spot);
} else selectedSpots = [spot];
propPanel.style.opacity = selectedSpots.length > 0 ? '1' : '0.5';
propPanel.style.pointerEvents = selectedSpots.length > 0 ? 'auto' : 'none';
updateVisuals();
};
div.oncontextmenu = (e) => {
e.preventDefault(); e.stopPropagation();
if(!selectedSpots.includes(spot)) { selectedSpots = [spot]; updateVisuals(); }
showContextMenu(e, 'spot', spot, false);
};
spotContainer.appendChild(div);
}
}
paper.project.deselectAll();
adjustMarkers.removeChildren();
appLayers.forEach(layer => {
layer.children.forEach(spot => {
if (!spot.name || !spot.name.startsWith('Spot_')) return;
let base = spot.children['basePath'];
let renderGrp = spot.children['renderGroup'];
if (spot.data.isVisible === false || layer.data.isVisible === false) {
base.visible = false; base.selected = false;
if(renderGrp) renderGrp.visible = false; return;
}
base.selected = false; base.strokeColor = null; base.strokeWidth = 0; base.fillColor = null;
if (mode === 'adjust' && selectedSpots.includes(spot)) {
base.visible = true; base.selected = false;
base.strokeColor = '#00aaff'; base.strokeWidth = 1.5; base.fillColor = 'rgba(0,170,255,0.05)';
base.bringToFront();
let segs = getPathSegments(base);
segs.forEach(seg => {
let isSel = selectedSegments.includes(seg);
seg.selected = isSel;
if (!isSel) {
let dot = new paper.Path.Circle({center: seg.point, radius: 3, fillColor: '#ffffff', strokeColor: '#555555', strokeWidth: 1});
adjustMarkers.addChild(dot);
}
});
if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.5; }
} else if ((mode === 'draw' || mode === 'erase' || mode === 'fill') && selectedSpots.includes(spot)) {
base.visible = true; base.fillColor = 'rgba(255, 255, 255, 0.01)';
if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 0.8; }
} else {
base.visible = false;
if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = 1.0; }
}
if (!spot.data.isRendered && mode !== 'adjust') {
base.visible = true; base.fillColor = 'rgba(200, 200, 200, 0.5)';
if (renderGrp) renderGrp.visible = false;
}
});
});
if (['select','move','rotate','scale'].includes(mode)) updateSelectionBounds();
else if (selectionRect) { selectionRect.remove(); selectionRect = null; }
paper.view.update();
}
window.setMode = function(newMode) {
commitVector();
mode = newMode;
if (mode !== 'adjust') {
activeSegment = null; activeHandle = null; adjustTargetLocation = null;
selectedSegments = [];
// selectedSpots は維持する(モード切替で選択解除しない)
paper.project.deselectAll();
}
document.querySelectorAll('.tool-btn').forEach(btn => btn.classList.remove('active'));
let btn = document.getElementById('btn-' + mode);
if(btn) btn.classList.add('active');
const texts = { 'draw': '描く', 'erase': '消す', 'fill':'塗る', 'select': '選択・複数選択', 'move': '移動', 'rotate':'回転', 'scale':'拡縮', 'adjust': 'ベジェ調整' };
document.getElementById('mode-text').innerText = texts[mode] || mode;
document.getElementById('myCanvas').style.cursor = (['select','move','rotate','scale'].includes(mode)) ? 'default' : 'crosshair';
updateVisuals();
};
document.getElementById('tab-layers').addEventListener('contextmenu', (e) => {
e.preventDefault(); if (e.target.closest('.list-item')) return;
showContextMenu(e, 'layer', null, true);
});
document.getElementById('tab-spots').addEventListener('contextmenu', (e) => {
e.preventDefault(); if (e.target.closest('.list-item')) return;
showContextMenu(e, 'spot', null, true);
});
function showContextMenu(e, type, item, isBackground = false) {
cmTargetType = type; cmTargetItem = item;
const menu = document.getElementById('context-menu');
const items = menu.children;
for(let i=0; i<items.length; i++) {
let action = items[i].getAttribute('data-action');
if(!action) continue;
if (isBackground) {
if (action === 'add' || action === 'paste') items[i].style.display = 'block'; else items[i].style.display = 'none';
} else {
if (action === 'add' || action === 'sep1') items[i].style.display = 'none';
else if (type === 'layer' && ['bringFront', 'bringForward', 'sendBackward', 'sendBack', 'sep_order'].includes(action)) items[i].style.display = 'none';
else items[i].style.display = 'block';
}
}
menu.style.display = 'block';
let x = e.clientX; let y = e.clientY;
if(x + menu.offsetWidth > window.innerWidth) x -= menu.offsetWidth;
if(y + menu.offsetHeight > window.innerHeight) y -= menu.offsetHeight;
menu.style.left = x + 'px'; menu.style.top = y + 'px';
}
window.addEventListener('click', (e) => { document.querySelectorAll('.popup-menu').forEach(m => m.style.display = 'none'); });
window.cmAction = function(action) {
if(cmTargetType === 'layer') handleLayerAction(action); else if(cmTargetType === 'spot') handleSpotAction(action);
}
function handleLayerAction(action) {
if(action === 'add') { activeLayer = createNewLayer(); selectedLayers = [activeLayer]; clearSelection(); saveState(); }
else if(action === 'cut' || action === 'copy') {
if(selectedLayers.length > 0) { clipboardData = selectedLayers[0].exportJSON(); clipboardType = 'layer'; if(action === 'cut') { selectedLayers[0].remove(); appLayers = appLayers.filter(l => l !== selectedLayers[0]); saveState(); } }
} else if(action === 'paste') {
if(clipboardType === 'layer' && clipboardData) {
let newL = new paper.Layer(); newL.importJSON(clipboardData); layerCounter++; newL.data.displayName = 'ペーストされたレイヤー ' + layerCounter;
appLayers.unshift(newL); activeLayer = newL; selectedLayers = [newL];
newL.children.forEach(spot => { if(spot.name && spot.data.isRendered) renderFluffySpot(spot); }); saveState();
}
} else if(action === 'rename') {
if(selectedLayers.length > 0) { let newName = prompt('新しい名前を入力:', selectedLayers[0].data.displayName); if(newName) { selectedLayers[0].data.displayName = newName; saveState(); } }
} else if(action === 'delete') {
selectedLayers.forEach(l => { l.remove(); appLayers = appLayers.filter(al => al !== l); });
selectedLayers = []; activeLayer = appLayers.length > 0 ? appLayers[0] : createNewLayer(); saveState();
} else if(action === 'opacity') {
if(selectedLayers.length > 0) { let op = prompt('透明度を 0.0 ~ 1.0 の間で入力:', selectedLayers[0].opacity); if(op !== null && !isNaN(parseFloat(op))) { selectedLayers.forEach(l => l.opacity = parseFloat(op)); saveState(); } }
} else if(action === 'merge') {
if(selectedLayers.length > 1) {
let mergedLayer = createNewLayer(); mergedLayer.data.displayName = '結合レイヤー ' + layerCounter;
selectedLayers.slice().reverse().forEach(l => { let spots = l.children.filter(i => i.name && i.name.startsWith('Spot_')).slice(); spots.forEach(s => mergedLayer.addChild(s)); l.remove(); appLayers = appLayers.filter(al => al !== l); });
selectedLayers = [mergedLayer]; activeLayer = mergedLayer; saveState();
} else alert("結合するにはCtrlキー(MacはCmd)を押しながら複数のレイヤーを選択してください。");
}
updateVisuals();
}
function handleSpotAction(action) {
commitVector();
if(action === 'add') { execAction('addSpot'); }
else if(action === 'cut' || action === 'copy') {
if(selectedSpots.length > 0) { clipboardData = selectedSpots[0].exportJSON(); clipboardType = 'spot'; if(action === 'cut') execAction('delete'); }
} else if(action === 'paste') {
if(clipboardType === 'spot' && clipboardData && activeLayer) {
activeLayer.activate(); let pasted = new paper.Group(); pasted.importJSON(clipboardData); pasted.name = 'Spot_' + Date.now();
pasted.data.displayName = pasted.data.displayName + ' (コピー)'; pasted.position.x += 20; pasted.position.y += 20;
activeLayer.addChild(pasted); if (pasted.data.isRendered) renderFluffySpot(pasted);
selectedSpots = [pasted]; saveState();
}
} else if(action === 'rename') {
if(selectedSpots.length > 0) { let newName = prompt('新しい名前を入力:', selectedSpots[0].data.displayName); if(newName) { selectedSpots[0].data.displayName = newName; saveState(); } }
} else if(action === 'delete') { execAction('delete'); }
else if(action === 'opacity') {
if(selectedSpots.length > 0) { let op = prompt('透明度を 0.0 ~ 1.0 の間で入力:', selectedSpots[0].opacity); if(op !== null && !isNaN(parseFloat(op))) { selectedSpots.forEach(s => s.opacity = parseFloat(op)); saveState(); } }
} else if(action === 'merge') {
if(selectedSpots.length > 1) execAction('unite'); else alert("結合するにはCtrlキー(MacはCmd)を押しながら複数のスポットを選択してください。");
}
else if(action === 'bringFront') { selectedSpots.forEach(s => s.bringToFront()); saveState(); }
else if(action === 'bringForward') { selectedSpots.forEach(spot => { let parent = spot.parent; let idx = parent.children.indexOf(spot); if(idx < parent.children.length - 1) parent.insertChild(idx + 1, spot); }); saveState(); }
else if(action === 'sendBackward') { selectedSpots.forEach(spot => { let parent = spot.parent; let idx = parent.children.indexOf(spot); if(idx > 0) parent.insertChild(idx - 1, spot); }); saveState(); }
else if(action === 'sendBack') { selectedSpots.forEach(spot => { let parent = spot.parent; parent.insertChild(0, spot); }); saveState(); }
updateVisuals();
}
function hexToRgb01(hex) {
let r = parseInt(hex.slice(1,3),16)/255;
let g = parseInt(hex.slice(3,5),16)/255;
let b = parseInt(hex.slice(5,7),16)/255;
return [r,g,b];
}
function rasterizePathToMask(basePath, W, H, offX, offY) {
const mc = document.createElement('canvas');
mc.width = W; mc.height = H;
const mctx = mc.getContext('2d');
mctx.clearRect(0, 0, W, H);
let pathData = '';
try {
if (basePath instanceof paper.CompoundPath) {
pathData = basePath.children.map(c => c.pathData || '').join(' ').trim();
} else {
pathData = (basePath.pathData || '').trim();
}
} catch(e) { pathData = ''; }
if (!pathData) return mctx.getImageData(0, 0, W, H);
mctx.save();
mctx.translate(-offX, -offY);
try {
const p2d = new Path2D(pathData);
mctx.fillStyle = 'rgba(255,255,255,1)';
mctx.fill(p2d, 'nonzero');
} catch(e) {}
mctx.restore();
return mctx.getImageData(0, 0, W, H);
}
function computeDistanceField(maskData, W, H) {
const INF = 1e9;
const seedX = new Int32Array(W * H).fill(-1);
const seedY = new Int32Array(W * H).fill(-1);
// エッジピクセル(内側で隣に外側がある)だけをシードにする
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = y * W + x;
if (maskData.data[idx * 4 + 3] <= 128) continue;
const isEdge = (
(x === 0 || maskData.data[(y*W+x-1)*4+3] <= 128) ||
(x === W-1 || maskData.data[(y*W+x+1)*4+3] <= 128) ||
(y === 0 || maskData.data[((y-1)*W+x)*4+3] <= 128) ||
(y === H-1 || maskData.data[((y+1)*W+x)*4+3] <= 128)
);
if (isEdge) { seedX[idx] = x; seedY[idx] = y; }
}
}
const tempSX = new Int32Array(W * H);
const tempSY = new Int32Array(W * H);
let step = 1;
while (step < Math.max(W, H)) step <<= 1;
step >>= 1;
while (step >= 1) {
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = y * W + x;
tempSX[idx] = seedX[idx];
tempSY[idx] = seedY[idx];
let bestDist = (seedX[idx] >= 0) ? (x - seedX[idx]) * (x - seedX[idx]) + (y - seedY[idx]) * (y - seedY[idx]) : INF;
for (let dy = -1; dy <= 1; dy++) {
for (let dx = -1; dx <= 1; dx++) {
if (dx === 0 && dy === 0) continue;
const nx2 = x + dx * step;
const ny2 = y + dy * step;
if (nx2 < 0 || nx2 >= W || ny2 < 0 || ny2 >= H) continue;
const nidx = ny2 * W + nx2;
if (seedX[nidx] < 0) continue;
const d = (x - seedX[nidx]) * (x - seedX[nidx]) + (y - seedY[nidx]) * (y - seedY[nidx]);
if (d < bestDist) { bestDist = d; tempSX[idx] = seedX[nidx]; tempSY[idx] = seedY[nidx]; }
}
}
}
}
seedX.set(tempSX); seedY.set(tempSY);
step >>= 1;
}
const dist = new Float32Array(W * H);
for (let i = 0; i < W * H; i++) {
if (maskData.data[i * 4 + 3] <= 128) { dist[i] = 0; continue; }
if (seedX[i] < 0) { dist[i] = 0; continue; }
const ix = i % W, iy = Math.floor(i / W);
dist[i] = Math.sqrt((ix - seedX[i]) * (ix - seedX[i]) + (iy - seedY[i]) * (iy - seedY[i]));
}
return dist;
}
function buildNormalMapFromDist(dist, W, H, hillStrength, curvePow) {
let maxDist = 0;
for (let i = 0; i < dist.length; i++) { if (dist[i] > maxDist) maxDist = dist[i]; }
if (maxDist < 1) maxDist = 1;
const height = new Float32Array(W * H);
for (let i = 0; i < W * H; i++) {
const nd = dist[i] / maxDist;
height[i] = nd > 0 ? Math.pow(nd, 1.0 / curvePow) : 0;
}
const bumpScale = hillStrength * maxDist * 6.0;
const normalMap = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = y * W + x;
if (height[idx] === 0) {
normalMap[idx*4+0] = 128; normalMap[idx*4+1] = 128; normalMap[idx*4+2] = 255; normalMap[idx*4+3] = 0; continue;
}
const hR = (x+1 < W) ? height[y*W + x+1] : 0;
const hL = (x-1 >= 0) ? height[y*W + x-1] : 0;
const hD = (y+1 < H) ? height[(y+1)*W + x] : 0;
const hU = (y-1 >= 0) ? height[(y-1)*W + x] : 0;
const dhdx = (hR - hL) * 0.5; const dhdy = (hD - hU) * 0.5;
const sx = -dhdx * bumpScale; const sy = -dhdy * bumpScale;
const len = Math.sqrt(sx*sx + sy*sy + 1.0);
const nx = sx / len; const ny = sy / len; const nz = 1.0 / len;
normalMap[idx*4+0] = Math.round((nx * 0.5 + 0.5) * 255);
normalMap[idx*4+1] = Math.round((ny * 0.5 + 0.5) * 255);
normalMap[idx*4+2] = Math.round((nz * 0.5 + 0.5) * 255);
normalMap[idx*4+3] = Math.round(height[idx] * 255);
}
}
return normalMap;
}
// ==========================================
// ユーザー提供の完全改良版 OBJエクスポート処理
// ==========================================
function exportOBJ() {
if (selectedSpots.length === 0) { alert('3Dエクスポートするスポットを選択してください。'); return; }
const spotGroup = selectedSpots[0];
const data = spotGroup.data;
const basePath = spotGroup.children['basePath'];
if (!basePath || basePath.isEmpty()) return;
const bounds = basePath.bounds;
const pad = 10;
const W = Math.ceil(bounds.width) + pad * 2;
const H = Math.ceil(bounds.height) + pad * 2;
const offX = Math.floor(bounds.left) - pad;
const offY = Math.floor(bounds.top) - pad;
const maskData = rasterizePathToMask(basePath, W, H, offX, offY);
const dist = computeDistanceField(maskData, W, H);
let maxDist = 0;
for (let i = 0; i < dist.length; i++) if (dist[i] > maxDist) maxDist = dist[i];
if (maxDist < 1) maxDist = 1;
const curvePow = 2.0;
const hillZ = (data.hillHeight || 60) / 100.0 * 50.0;
let objStr = "# Liquid Metal Painter 3D Export - improved\n";
objStr += "o PuffySticker\n";
const segX = Math.min(W, 150);
const segY = Math.min(H, 150);
const vertsCountX = segX + 1;
const vertsCountY = segY + 1;
const totalTopVerts = vertsCountX * vertsCountY;
const heightGrid = new Float32Array(totalTopVerts);
const spacingX = W / segX;
const spacingY = H / segY;
for (let iy = 0; iy <= segY; iy++) {
const ty = iy / segY;
const py = Math.min(H - 1, Math.floor(ty * H));
for (let ix = 0; ix <= segX; ix++) {
const tx = ix / segX;
const px = Math.min(W - 1, Math.floor(tx * W));
const d = dist[py * W + px];
const nd = d / maxDist;
const h = nd > 0 ? Math.pow(nd, 1.0 / curvePow) : 0;
const z = h * hillZ;
const x = (tx * W) - (W / 2);
const y = -((ty * H) - (H / 2));
objStr += `v ${x.toFixed(6)} ${y.toFixed(6)} ${z.toFixed(6)}\n`;
objStr += `vt ${tx.toFixed(6)} ${(1.0 - ty).toFixed(6)}\n`;
const idx = iy * vertsCountX + ix;
heightGrid[idx] = z;
}
}
const normals = new Float32Array(totalTopVerts * 3);
for (let iy = 0; iy <= segY; iy++) {
for (let ix = 0; ix <= segX; ix++) {
const idx = iy * vertsCountX + ix;
const ixL = Math.max(0, ix - 1);
const ixR = Math.min(vertsCountX - 1, ix + 1);
const iyU = Math.max(0, iy - 1);
const iyD = Math.min(vertsCountY - 1, iy + 1);
const hL = heightGrid[iy * vertsCountX + ixL];
const hR = heightGrid[iy * vertsCountX + ixR];
const hU = heightGrid[iyU * vertsCountX + ix];
const hD = heightGrid[iyD * vertsCountX + ix];
const dx = (hR - hL) / ( (ixR - ixL) * spacingX + 1e-9 );
const dy = (hD - hU) / ( (iyD - iyU) * spacingY + 1e-9 );
let nx = -dx;
let ny = -dy;
let nz = 1.0;
const len = Math.sqrt(nx*nx + ny*ny + nz*nz) + 1e-12;
nx /= len; ny /= len; nz /= len;
normals[idx*3+0] = nx;
normals[idx*3+1] = ny;
normals[idx*3+2] = nz;
objStr += `vn ${nx.toFixed(6)} ${ny.toFixed(6)} ${nz.toFixed(6)}\n`;
}
}
for (let iy = 0; iy < segY; iy++) {
for (let ix = 0; ix < segX; ix++) {
const i0 = iy * vertsCountX + ix + 1;
const i1 = i0 + 1;
const i2 = i0 + vertsCountX;
const i3 = i2 + 1;
objStr += `f ${i0}/${i0}/${i0} ${i1}/${i1}/${i1} ${i2}/${i2}/${i2}\n`;
objStr += `f ${i2}/${i2}/${i2} ${i1}/${i1}/${i1} ${i3}/${i3}/${i3}\n`;
}
}
const bottomOffset = totalTopVerts;
for (let iy = 0; iy <= segY; iy++) {
for (let ix = 0; ix <= segX; ix++) {
const tx = ix / segX;
const ty = iy / segY;
const x = (tx * W) - (W / 2);
const y = -((ty * H) - (H / 2));
const zBottom = 0.0;
objStr += `v ${x.toFixed(6)} ${y.toFixed(6)} ${zBottom.toFixed(6)}\n`;
objStr += `vt ${tx.toFixed(6)} ${(1.0 - ty).toFixed(6)}\n`;
objStr += `vn 0.000000 0.000000 -1.000000\n`;
}
}
for (let iy = 0; iy < segY; iy++) {
for (let ix = 0; ix < segX; ix++) {
const bi0 = bottomOffset + (iy * vertsCountX + ix) + 1;
const bi1 = bi0 + 1;
const bi2 = bi0 + vertsCountX;
const bi3 = bi2 + 1;
objStr += `f ${bi2}/${bi2}/${bi2} ${bi1}/${bi1}/${bi1} ${bi0}/${bi0}/${bi0}\n`;
objStr += `f ${bi3}/${bi3}/${bi3} ${bi1}/${bi1}/${bi1} ${bi2}/${bi2}/${bi2}\n`;
}
}
function topIndex(ix, iy) { return (iy * vertsCountX + ix) + 1; }
function bottomIndex(ix, iy) { return bottomOffset + (iy * vertsCountX + ix) + 1; }
for (let iy = 0; iy < vertsCountY - 1; iy++) {
{
const t0 = topIndex(0, iy), t1 = topIndex(0, iy+1);
const b0 = bottomIndex(0, iy), b1 = bottomIndex(0, iy+1);
objStr += `f ${t1}/${t1}/${t1} ${t0}/${t0}/${t0} ${b0}/${b0}/${b0}\n`;
objStr += `f ${b0}/${b0}/${b0} ${b1}/${b1}/${b1} ${t1}/${t1}/${t1}\n`;
}
{
const t0 = topIndex(segX, iy), t1 = topIndex(segX, iy+1);
const b0 = bottomIndex(segX, iy), b1 = bottomIndex(segX, iy+1);
objStr += `f ${t0}/${t0}/${t0} ${t1}/${t1}/${t1} ${b0}/${b0}/${b0}\n`;
objStr += `f ${b0}/${b0}/${b0} ${t1}/${t1}/${t1} ${b1}/${b1}/${b1}\n`;
}
}
for (let ix = 0; ix < vertsCountX - 1; ix++) {
{
const t0 = topIndex(ix, 0), t1 = topIndex(ix+1, 0);
const b0 = bottomIndex(ix, 0), b1 = bottomIndex(ix+1, 0);
objStr += `f ${t0}/${t0}/${t0} ${t1}/${t1}/${t1} ${b0}/${b0}/${b0}\n`;
objStr += `f ${b0}/${b0}/${b0} ${t1}/${t1}/${t1} ${b1}/${b1}/${b1}\n`;
}
{
const t0 = topIndex(ix, segY), t1 = topIndex(ix+1, segY);
const b0 = bottomIndex(ix, segY), b1 = bottomIndex(ix+1, segY);
objStr += `f ${t1}/${t1}/${t1} ${t0}/${t0}/${t0} ${b0}/${b0}/${b0}\n`;
objStr += `f ${b0}/${b0}/${b0} ${t0}/${t0}/${t0} ${b1}/${b1}/${b1}\n`;
}
}
const blob = new Blob([objStr], { type: "text/plain" });
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'puffy_model.obj'; a.click(); URL.revokeObjectURL(url);
console.log("OBJ exported: vertices top =", totalTopVerts, ", bottom offset =", bottomOffset);
}
function renderLiquidMetalSpot(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 bounds = basePath.bounds;
const pad = 20;
const W = Math.ceil(bounds.width) + pad * 2;
const H = Math.ceil(bounds.height) + pad * 2;
const offX = Math.floor(bounds.left) - pad;
const offY = Math.floor(bounds.top) - pad;
const maskImageData = rasterizePathToMask(basePath, W, H, offX, offY);
let maskPixels = 0;
for (let i = 3; i < maskImageData.data.length; i += 4) {
if (maskImageData.data[i] > 128) maskPixels++;
}
if (maskPixels === 0) return;
const dist = computeDistanceField(maskImageData, W, H);
const finalCanvas = renderBumpLit(W, H, maskImageData, dist, data);
const renderGroup = new paper.Group({ name: 'renderGroup' });
const raster = new paper.Raster(finalCanvas);
raster.position = new paper.Point(offX + W/2, offY + H/2);
renderGroup.addChild(raster);
spotGroup.addChild(renderGroup);
if (selectedSpots.length === 1 && selectedSpots[0] === spotGroup) {
uiCenterCol.value = data.centerCol || '#ffffff';
uiMidCol.value = data.midCol || '#8ab4cc';
uiEdgeCol.value = data.edgeCol || '#1a2a3a';
document.getElementById('dragHandleCenter').style.backgroundColor = data.centerCol || '#ffffff';
document.getElementById('dragHandleMid').style.backgroundColor = data.midCol || '#8ab4cc';
document.getElementById('dragHandleEdge').style.backgroundColor = data.edgeCol || '#1a2a3a';
uiBlur.value = data.blurAmt ?? 55; document.getElementById('valBlur').innerText = data.blurAmt ?? 55;
uiDepth.value = data.depthAmt ?? 12; document.getElementById('valDepth').innerText = data.depthAmt ?? 12;
uiLightAngle.value = data.lightAngle || 135; document.getElementById('valLightAngle').innerText = data.lightAngle || 135;
uiLightIntensity.value = data.lightIntensity || 90; document.getElementById('valLightIntensity').innerText = data.lightIntensity || 90;
uiHillHeight.value = data.hillHeight || 75; document.getElementById('valHillHeight').innerText = data.hillHeight || 75;
uiTexture.value = data.texture || 'water';
uiPattern.value = data.patternType || 'none';
const preview = document.getElementById('texturePreview');
if (data.customTexture) { preview.src = data.customTexture; preview.style.display = 'block'; }
else { preview.style.display = 'none'; preview.src = ''; }
}
}
function renderBumpLit(W, H, maskImageData, dist, data) {
let maxDist = 0;
for (let i = 0; i < dist.length; i++) if (dist[i] > maxDist) maxDist = dist[i];
if (maxDist < 1) maxDist = 1;
// hillHeight(5-100): 丘の高さ。大きいほど高く盛り上がる
const hillScale = Math.max(0.05, (data.hillHeight || 75) / 100.0);
// blurAmt(1-80): 反射の粗さ。curvePowに反映(小さい=急峻、大きい=なだらか)
const curvePow = 0.2 + ((data.blurAmt ?? 55) / 80.0) * 1.3;
// depthAmt(3-50): エッジの鋭さ。bumpStrの係数に反映
const depthScale = Math.max(0.3, (data.depthAmt ?? 12) / 18.0);
// 高さマップ: pow(nd, curvePow) * hillScale
const height = new Float32Array(W * H);
for (let i = 0; i < W * H; i++) {
if (maskImageData.data[i * 4 + 3] <= 128) { height[i] = 0; continue; }
const nd = dist[i] / maxDist;
height[i] = Math.pow(nd, curvePow) * hillScale;
}
// 法線: hillScaleを除いた純粋な形状カーブでdhPerPxを計算し、
// bumpStrにhillScaleを掛けて「高さが高いほど傾きも急」を正しく反映
const hAt1 = Math.pow(1/maxDist, curvePow);
const hAt2 = Math.pow(2/maxDist, curvePow);
const dhPerPx = Math.max(hAt2 - hAt1, 0.00001);
const bumpStr = (Math.tan(50 * Math.PI / 180) * depthScale * hillScale) / dhPerPx;
const Nx = new Float32Array(W * H);
const Ny = new Float32Array(W * H);
const Nz = new Float32Array(W * H);
for (let y = 0; y < H; y++) {
for (let x = 0; x < W; x++) {
const idx = y * W + x;
if (maskImageData.data[idx * 4 + 3] <= 128) { Nz[idx]=1; continue; }
const hC = height[idx];
const hR = (x < W-1) ? height[y*W+x+1] : hC;
const hL = (x > 0) ? height[y*W+x-1] : hC;
const hDn = (y < H-1) ? height[(y+1)*W+x] : hC;
const hUp = (y > 0) ? height[(y-1)*W+x] : hC;
let sx = -(hR - hL) * 0.5 * bumpStr;
let sy = -(hDn - hUp) * 0.5 * bumpStr;
const nlen = Math.sqrt(sx*sx + sy*sy + 1.0) + 1e-9;
Nx[idx] = sx/nlen; Ny[idx] = sy/nlen; Nz[idx] = 1.0/nlen;
}
}
// 光源方向(lightAngle)
const lightAng = (data.lightAngle || 45) * Math.PI / 180;
const li = Math.max(0.4, (data.lightIntensity || 80) / 80.0);
const Lx = Math.cos(lightAng)*0.6, Ly = -Math.sin(lightAng)*0.6, Lz = 0.8;
const Llen = Math.sqrt(Lx*Lx+Ly*Ly+Lz*Lz);
const lx=Lx/Llen, ly=Ly/Llen, lz=Lz/Llen;
const Hlen2 = Math.sqrt(lx*lx+ly*ly+(lz+1)*(lz+1));
const Hhx=lx/Hlen2, Hhy=ly/Hlen2, Hhz=(lz+1)/Hlen2;
function hex2rgb(h) {
h=(h||'#888888').replace('#','');
return [parseInt(h.slice(0,2),16)/255,parseInt(h.slice(2,4),16)/255,parseInt(h.slice(4,6),16)/255];
}
const cH=hex2rgb(data.centerCol||'#ffffff');
const cM=hex2rgb(data.midCol||'#8ab4cc');
const cE=hex2rgb(data.edgeCol||'#1a2a3a');
// テクスチャ種類: スペキュラーパワー/強度/リム強度
const tt=data.texture||'water';
const SP={water:80,sticker:40,chrome:200,gold:60,mercury:300,brushed:15,pearl:30,none:20};
const SS={water:1.8,sticker:0.9,chrome:2.5,gold:1.8,mercury:3.5,brushed:0.4,pearl:1.2,none:0.2};
const RS={water:0.5,sticker:0.6,chrome:0.7,gold:0.4,mercury:0.8,brushed:0.1,pearl:1.0,none:0.1};
const specPow=SP[tt]||60, specStr=SS[tt]||1.0, rimStr=RS[tt]||0.3;
let texPixels=null, texW=0, texH=0;
if(data.customTextureObj&&data.customTextureObj.complete){
const tc=document.createElement('canvas');
texW=tc.width=data.customTextureObj.naturalWidth||1;
texH=tc.height=data.customTextureObj.naturalHeight||1;
tc.getContext('2d').drawImage(data.customTextureObj,0,0);
texPixels=tc.getContext('2d').getImageData(0,0,texW,texH).data;
}
const outCanvas=document.createElement('canvas');
outCanvas.width=W; outCanvas.height=H;
const outCtx=outCanvas.getContext('2d');
const imgData=outCtx.createImageData(W,H);
const px=imgData.data;
for(let y=0;y<H;y++) for(let x=0;x<W;x++){
const i=y*W+x;
const alpha=maskImageData.data[i*4+3];
if(alpha<=128){px[i*4+3]=0;continue;}
const ht=height[i] / hillScale; // 正規化: 0〜1
const nx=Nx[i],ny=Ny[i],nz=Nz[i];
const NdotL=Math.max(nx*lx+ny*ly+nz*lz,0);
const NdotV=Math.max(nz,0.001);
const NdotH=Math.max(nx*Hhx+ny*Hhy+nz*Hhz,0);
const spec=Math.pow(NdotH,specPow)*specStr;
const rim=Math.pow(Math.max(1-NdotV,0),3)*rimStr;
const t=Math.min(ht*2,1);
let br=cE[0]+(cM[0]-cE[0])*t;
let bg=cE[1]+(cM[1]-cE[1])*t;
let bb=cE[2]+(cM[2]-cE[2])*t;
if(texPixels&&texW>0){
const tx=Math.floor((x/W)*texW)%texW, ty2=Math.floor((y/H)*texH)%texH;
const ta=texPixels[(ty2*texW+tx)*4+3]/255, ti=(ty2*texW+tx)*4;
br=br*(1-ta*0.8)+(texPixels[ti]/255)*ta*0.8;
bg=bg*(1-ta*0.8)+(texPixels[ti+1]/255)*ta*0.8;
bb=bb*(1-ta*0.8)+(texPixels[ti+2]/255)*ta*0.8;
}
const dome=0.2+0.8*ht;
const diff=NdotL*0.7+0.3;
px[i*4+0]=Math.min(Math.max((br*diff*dome+cH[0]*(spec+rim))*li*255,0),255);
px[i*4+1]=Math.min(Math.max((bg*diff*dome+cH[1]*(spec+rim))*li*255,0),255);
px[i*4+2]=Math.min(Math.max((bb*diff*dome+cH[2]*(spec+rim))*li*255,0),255);
px[i*4+3]=alpha;
}
outCtx.putImageData(imgData,0,0);
return outCanvas;
}
function renderFluffySpot(spotGroup) { renderLiquidMetalSpot(spotGroup); }
function createTaperedPath(pointsData, maxRadius) {
if (pointsData.length < 2) return new paper.Path.Circle({ center: pointsData[0].point, radius: Math.max(0.5, maxRadius / 2 * pointsData[0].pressure) });
let spinePoints = pointsData.map(p => p.point);
let spine = new paper.Path({ segments: spinePoints }); spine.simplify(2); let totalLength = spine.length;
if (totalLength < 1) { spine.remove(); return new paper.Path.Circle({ center: spinePoints[0], radius: Math.max(0.5, maxRadius / 2 * pointsData[0].pressure) }); }
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; let offset = t * totalLength;
let point = spine.getPointAt(offset); let normal = spine.getNormalAt(offset);
if (!point || !normal) continue;
let ratio = t * (pointsData.length - 1); let idx = Math.floor(ratio); let rem = ratio - idx;
let pressure = 1;
if (idx < pointsData.length - 1) { pressure = pointsData[idx].pressure * (1 - rem) + pointsData[idx+1].pressure * rem; }
else { pressure = pointsData[idx].pressure; }
let radius = Math.max(0.5, Math.sin(t * Math.PI) * maxRadius * pressure);
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;
let actionCenter = null; let startAngle = 0; let startDist = 0;
let adjustTargetLocation = null;
tool.onMouseDown = function(event) {
if (event.event.button === 2) {
if (mode === 'adjust' && selectedSpots.length > 0) {
let targetSpot = selectedSpots[0];
let hitResultPath = targetSpot.children['basePath'].hitTest(event.point, { segments: true, curve: true, stroke: true, tolerance: 8 });
if (hitResultPath) {
if (hitResultPath.type === 'segment') { activeSegment = hitResultPath.segment; adjustTargetLocation = null; }
else if (hitResultPath.type === 'curve' || hitResultPath.type === 'stroke') { adjustTargetLocation = hitResultPath.location; activeSegment = null; }
} else { adjustTargetLocation = null; }
const menu = document.getElementById('adjust-menu');
const itemAdd = document.getElementById('am-add'); const itemDel = document.getElementById('am-delete');
itemAdd.style.display = adjustTargetLocation ? 'block' : 'none'; itemDel.style.display = activeSegment ? 'block' : 'none';
if (adjustTargetLocation || activeSegment) {
menu.style.display = 'block'; let x = event.event.clientX; let y = event.event.clientY;
if(x + menu.offsetWidth > window.innerWidth) x -= menu.offsetWidth; if(y + menu.offsetHeight > window.innerHeight) y -= menu.offsetHeight;
menu.style.left = x + 'px'; menu.style.top = y + 'px';
}
} return;
}
const hitResult = paper.project.hitTest(event.point, { fill: true, stroke: true, tolerance: 4 });
if (!hitResult && ['select', 'move', 'rotate', 'scale'].includes(mode)) { isPanning = true; return; }
isPanning = false;
if (mode === 'select') {
if (hitResult) {
let item = hitResult.item; let spotItem = null;
while(item) { if(item.name && item.name.startsWith('Spot_')) { spotItem = item; break; } item = item.parent; }
if(spotItem) selectSpot(spotItem, event.modifiers.control || event.modifiers.command); else clearSelection();
} else clearSelection(); return;
}
if (mode === 'adjust') {
if (selectedSpots.length === 0) return;
let bp = selectedSpots[0].children['basePath']; let hitResultPath = bp.hitTest(event.point, { segments: true, handles: true, tolerance: 8 });
if (hitResultPath) {
if (hitResultPath.type === 'segment') {
if (event.modifiers.control || event.modifiers.command) {
let idx = selectedSegments.indexOf(hitResultPath.segment);
if (idx >= 0) selectedSegments.splice(idx, 1); else selectedSegments.push(hitResultPath.segment);
} else { if (!selectedSegments.includes(hitResultPath.segment)) selectedSegments = [hitResultPath.segment]; }
activeSegment = hitResultPath.segment; activeHandle = null;
}
else if (hitResultPath.type === 'handle-in' || hitResultPath.type === 'handle-out') {
activeHandle = hitResultPath.type; activeSegment = hitResultPath.segment;
if (!selectedSegments.includes(activeSegment)) selectedSegments = [activeSegment];
}
} else {
let curveHit = bp.hitTest(event.point, { curve: true, stroke: true, tolerance: 8 });
if (!curveHit || !(event.modifiers.control || event.modifiers.command)) { selectedSegments = []; activeSegment = null; activeHandle = null; }
}
updateVisuals(); return;
}
if (mode === 'fill') {
if (selectedSpots.length === 0) return;
commitVector(); let targetSpot = selectedSpots[0]; let basePath = targetSpot.children['basePath'];
let obstaclePath = new paper.Path();
appLayers.forEach(layer => {
if (layer.data.isVisible !== false) {
layer.children.forEach(spot => {
if (spot.name && spot.name.startsWith('Spot_') && spot.data.isVisible !== false) {
let b = spot.children['basePath'];
if (b && !b.isEmpty()) { let temp = obstaclePath.isEmpty() ? b.clone() : obstaclePath.unite(b); obstaclePath.remove(); obstaclePath = temp; }
}
});
}
});
let bgRect = new paper.Path.Rectangle(paper.view.bounds.expand(2000)); let inverted = bgRect.subtract(obstaclePath); obstaclePath.remove();
let targetRegion = null;
if (inverted instanceof paper.CompoundPath) {
let candidates = [];
for (let i = 0; i < inverted.children.length; i++) { if (inverted.children[i].contains(event.point)) candidates.push(inverted.children[i]); }
if (candidates.length > 0) { candidates.sort((a, b) => Math.abs(a.area) - Math.abs(b.area)); targetRegion = candidates[0]; }
} else if (inverted.contains(event.point)) { targetRegion = inverted; }
if (targetRegion) {
let united = basePath.isEmpty() ? targetRegion.clone() : basePath.unite(targetRegion);
targetSpot.children['basePath'].remove(); united.name = 'basePath'; targetSpot.addChild(united);
if (targetSpot.data.isRendered) renderFluffySpot(targetSpot); updateVisuals(); saveState();
}
inverted.remove(); bgRect.remove(); return;
}
if (mode === 'move' || mode === 'rotate' || mode === 'scale') {
if (selectedSpots.length === 0) return;
let bounds = selectedSpots[0].bounds; for(let i=1; i<selectedSpots.length; i++) bounds = bounds.unite(selectedSpots[i].bounds);
actionCenter = bounds.center; startAngle = (event.point.subtract(actionCenter)).angle; startDist = event.point.getDistance(actionCenter); return;
}
if (selectedSpots.length === 0) { alert("ドローするスポットを追加するか選択してください。"); setMode('select'); return; }
draftLayer.activate();
let press = event.event.pressure !== undefined && event.event.pressure > 0 ? event.event.pressure : 1;
strokePoints = [{ point: event.point, pressure: press }];
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 (isPanning) {
let ws = document.getElementById('workspace'); ws.scrollLeft -= event.event.movementX || 0; ws.scrollTop -= event.event.movementY || 0; return;
}
if (mode === 'select' || mode === 'move') { selectedSpots.forEach(spot => spot.position = spot.position.add(event.delta)); updateSelectionBounds(); return; }
if (mode === 'rotate' && actionCenter) {
let currentAngle = (event.point.subtract(actionCenter)).angle; let deltaAngle = currentAngle - startAngle;
selectedSpots.forEach(spot => spot.rotate(deltaAngle, actionCenter)); startAngle = currentAngle; updateSelectionBounds(); return;
}
if (mode === 'scale' && actionCenter && startDist > 0) {
let currentDist = event.point.getDistance(actionCenter); let scaleFactor = currentDist / startDist;
selectedSpots.forEach(spot => spot.scale(scaleFactor, actionCenter)); startDist = currentDist; updateSelectionBounds(); return;
}
if (mode === 'adjust') {
if (activeHandle === 'handle-in' && activeSegment) { activeSegment.handleIn = activeSegment.handleIn.add(event.delta); }
else if (activeHandle === 'handle-out' && activeSegment) { activeSegment.handleOut = activeSegment.handleOut.add(event.delta); }
else if (selectedSegments.length > 0) { selectedSegments.forEach(seg => { seg.point = seg.point.add(event.delta); }); }
return;
}
if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
if (event.point.getDistance(strokePoints[strokePoints.length - 1].point) > 2) {
let press = event.event.pressure !== undefined && event.event.pressure > 0 ? event.event.pressure : 1;
strokePoints.push({ point: event.point, pressure: press }); 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 (isPanning) { isPanning = false; return; }
if (mode === 'adjust') {
if (selectedSpots[0] && selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
updateSelectionBounds(); activeHandle = null; updateVisuals(); saveState(); return;
}
if ((mode === 'move' || mode === 'rotate' || mode === 'scale') && selectedSpots.length > 0 && actionCenter) {
selectedSpots.forEach(spot => { if (spot.data.isRendered) renderFluffySpot(spot); }); actionCenter = null; saveState();
}
if ((mode === 'draw' || mode === 'erase') && currentDraftPath) {
currentDraftPath.simplify(2); draftQueue.push({ path: currentDraftPath, mode: mode });
currentDraftPath = null; strokePoints = [];
}
};
window.adjustMenuAction = function(action) {
document.getElementById('adjust-menu').style.display = 'none';
if(action === 'add' && adjustTargetLocation) {
let newCurve = adjustTargetLocation.curve.divideAt(0.5);
if(newCurve) { selectedSegments = [newCurve.segment1]; activeSegment = newCurve.segment1; }
if(selectedSpots[0] && selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
updateVisuals(); saveState();
} else if(action === 'delete' && activeSegment) {
activeSegment.remove(); activeSegment = null; selectedSegments = [];
if(selectedSpots[0] && selectedSpots[0].data.isRendered) renderFluffySpot(selectedSpots[0]);
updateVisuals(); saveState();
}
adjustTargetLocation = null;
};
function selectSpot(spot, isMulti) {
commitVector();
if (mode === 'adjust') { activeSegment = null; activeHandle = null; adjustTargetLocation = null; selectedSegments = []; }
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';
if (selectedSpots.length > 0 && selectedSpots[0].parent && selectedSpots[0].parent.name && selectedSpots[0].parent.name.startsWith('Layer_')) {
activeLayer = selectedSpots[0].parent; selectedLayers = [activeLayer];
}
updateVisuals();
}
function clearSelection() {
commitVector();
if (mode === 'adjust') { activeSegment = null; activeHandle = null; adjustTargetLocation = null; selectedSegments = []; }
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 / paper.view.zoom, 4 / paper.view.zoom];
}
window.execAction = function(action) {
if (action === 'undo') { if (undoStack.length > 1) { redoStack.push(undoStack.pop()); restoreState(undoStack[undoStack.length - 1]); } return; }
if (action === 'redo') { if (redoStack.length > 0) { let state = redoStack.pop(); undoStack.push(state); restoreState(state); } return; }
commitVector();
if (action === 'addSpot') {
if(!activeLayer) { alert('レイヤーがありません。'); return; }
spotCounter++; const spotGroup = new paper.Group({ name: 'Spot_' + Date.now() });
spotGroup.data = { isRendered: false, isVisible: true, displayName: 'スポット ' + spotCounter, centerCol: uiCenterCol.value, midCol: uiMidCol.value, edgeCol: uiEdgeCol.value, patternType: uiPattern.value || 'none', blurAmt: parseInt(uiBlur.value), depthAmt: parseInt(uiDepth.value), lightAngle: parseInt(uiLightAngle.value) || 135, lightIntensity: parseInt(uiLightIntensity.value) || 90, hillHeight: parseInt(uiHillHeight.value) || 75, texture: uiTexture.value || 'water' };
const basePath = new paper.Path({ name: 'basePath', visible: false });
spotGroup.addChild(basePath); activeLayer.addChild(spotGroup);
selectSpot(spotGroup, false); setMode('draw'); saveState();
}
else if (action === 'vectorize') { updateVisuals(); }
else if (action === 'render') { if (selectedSpots.length > 0) { selectedSpots.forEach(spot => renderFluffySpot(spot)); setMode('select'); saveState(); } }
else if (action === 'delete') { selectedSpots.forEach(s => s.remove()); clearSelection(); saveState(); }
else if (action === 'unite') {
if (selectedSpots.length < 2) return;
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); let targetLayer = selectedSpots[0].parent;
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); targetLayer.addChild(newSpot); selectSpot(newSpot, false); saveState();
}
};
window.fileAction = function(action) {
commitVector();
if (action === 'new') {
if(confirm('現在の作業内容は失われます。新規作成しますか?')) {
appLayers.forEach(l => l.remove()); appLayers = []; draftLayer.removeChildren();
spotCounter = 0; layerCounter = 0; clearSelection(); activeLayer = createNewLayer(); execAction('addSpot');
}
} else if (action === 'save') {
let exportData = { layers: appLayers.map(l => l.exportJSON()) }; const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData));
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' || action === 'svg') {
const oldMode = mode; setMode('select'); clearSelection(); paper.project.deselectAll();
let previousZoom = currentZoom; changeZoom(null);
appLayers.forEach(layer => {
layer.children.forEach(spot => {
if (spot.name && spot.name.startsWith('Spot_') && spot.data.isVisible !== false) {
if (!spot.data.isRendered) renderFluffySpot(spot);
let base = spot.children['basePath']; if (base) { base.visible = false; base.selected = false; base.strokeColor = null; base.strokeWidth = 0; }
let renderGrp = spot.children['renderGroup']; if (renderGrp) { renderGrp.visible = true; renderGrp.opacity = spot.opacity; }
}
});
});
uiLayer.visible = false; draftLayer.visible = false; paper.view.update();
setTimeout(() => {
if (action === 'png') { const a = document.createElement('a'); a.href = document.getElementById('myCanvas').toDataURL('image/png'); a.download = 'fluffy_export.png'; a.click(); }
else if (action === 'svg') {
let svgStr = paper.project.exportSVG({asString: true}); let blob = new Blob([svgStr], {type: "image/svg+xml;charset=utf-8"});
let url = URL.createObjectURL(blob); let a = document.createElement('a'); a.href = url; a.download = 'fluffy_export.svg'; a.click(); URL.revokeObjectURL(url);
}
uiLayer.visible = true; draftLayer.visible = true; changeZoom(previousZoom); setMode(oldMode);
}, 100);
} else if (action === 'obj') {
exportOBJ();
} else if (action === 'resize') {
const w = prompt('新しい幅を入力', baseWidth); const h = prompt('新しい高さを入力', baseHeight);
if (w && h) { baseWidth = parseInt(w); baseHeight = parseInt(h); changeZoom(1); }
}
};
document.getElementById('fileLoader').addEventListener('change', function(e) {
const file = e.target.files[0]; if (!file) return; const reader = new FileReader();
reader.onload = function(evt) {
try {
let parsed = JSON.parse(evt.target.result); appLayers.forEach(l => l.remove()); appLayers = [];
if(parsed.layers) {
parsed.layers.forEach(lData => { let newL = new paper.Layer(); newL.importJSON(lData); appLayers.push(newL);
newL.children.forEach(spot => { if(spot.name && spot.name.startsWith('Spot_') && spot.data.isRendered) renderFluffySpot(spot); });
});
}
activeLayer = appLayers[0]; selectedLayers = [activeLayer]; clearSelection(); saveState();
} catch(err) { alert("ファイルの読み込みに失敗しました。"); }
}; reader.readAsText(file); this.value = '';
});
document.addEventListener('keydown', (e) => {
if (['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(e.key)) {
e.preventDefault(); const panStep = 30; let ws = document.getElementById('workspace');
if (e.key === 'ArrowUp') ws.scrollTop -= panStep; if (e.key === 'ArrowDown') ws.scrollTop += panStep;
if (e.key === 'ArrowLeft') ws.scrollLeft -= panStep; if (e.key === 'ArrowRight') ws.scrollLeft += panStep; return;
}
if (e.key === 'PageUp') { e.preventDefault(); changeZoom(1.2); }
if (e.key === 'PageDown') { e.preventDefault(); changeZoom(1 / 1.2); }
if (e.key === 'Delete') { if(selectedSpots.length > 0) cmAction('delete'); }
if (e.ctrlKey || e.metaKey) {
if (e.key.toLowerCase() === 'c') { e.preventDefault(); cmAction('copy'); }
if (e.key.toLowerCase() === 'v') { e.preventDefault(); cmAction('paste'); }
if (e.key.toLowerCase() === 'x') { e.preventDefault(); cmAction('cut'); }
if (e.key.toLowerCase() === 'j') { e.preventDefault(); cmAction('merge'); }
if (e.key.toLowerCase() === 's') { e.preventDefault(); fileAction('save'); }
if (e.key.toLowerCase() === 'z') { e.preventDefault(); execAction('undo'); }
if (e.key.toLowerCase() === 'y') { e.preventDefault(); execAction('redo'); }
}
});
changeZoom(null);
execAction('addSpot');
</script>
</body>
</html>

コメント