tarinai/js/circuit_board_system.js
2026-07-16 22:12:03 +09:00

1117 lines
52 KiB
JavaScript

"use strict";
// Resizable grid-based combinational logic board. Internal wiring is a graph of
// straight segments. Junctions are explicit movable nodes and terminals may
// fan out to any number of connections.
(function (global) {
const SIDES = Object.freeze(["left", "right", "top", "bottom"]);
const GATE_TYPES = Object.freeze(["and", "not", "rectifier", "on"]);
const GRID = 40;
const DEFAULT_COLS = 10;
const DEFAULT_ROWS = 10;
const MIN_COLS = 6;
const MAX_COLS = 20;
const MIN_ROWS = 4;
const MAX_ROWS = 20;
const HALF_W = 32;
const HALF_H = 32;
const CANVAS_W = DEFAULT_COLS * GRID;
const CANVAS_H = DEFAULT_ROWS * GRID;
const MAX_NODES = 36;
const MAX_WIRES = 384;
const clone = value => {
try { return JSON.parse(JSON.stringify(value)); } catch (_) { return null; }
};
const clamp = global.TarinaiCoreHelpers?.clampNumber || ((v, a, b) => Math.max(a, Math.min(b, Number(v) || 0)));
const safeRotation = value => ((Math.round(Number(value) || 0) % 4) + 4) % 4;
const safeSide = value => SIDES.includes(value) ? value : "left";
const nodeType = value => GATE_TYPES.includes(value) ? value : "and";
const nodeInputCount = type => type === "and" ? 2 : ((type === "not" || type === "rectifier") ? 1 : 0);
const safeCols = value => Math.round(clamp(value ?? DEFAULT_COLS, MIN_COLS, MAX_COLS));
const safeRows = value => Math.round(clamp(value ?? DEFAULT_ROWS, MIN_ROWS, MAX_ROWS));
const canvasWidth = config => safeCols(config?.cols) * GRID;
const canvasHeight = config => safeRows(config?.rows) * GRID;
const snap = value => Math.round((Number(value) || 0) / GRID) * GRID;
const isVerticalSide = side => side === "left" || side === "right";
const sideExtent = (config, side) => isVerticalSide(side) ? canvasHeight(config) : canvasWidth(config);
const safeOffset = (config, side, value) => {
const extent = sideExtent(config, side);
return clamp(snap(value), GRID, Math.max(GRID, extent - GRID));
};
const portLimit = config => Math.max(1, safeRows(config?.rows) - 1);
const pointToken = (config, x, y) => `p:${Math.round(clamp(snap(x), GRID, canvasWidth(config) - GRID))}:${Math.round(clamp(snap(y), GRID, canvasHeight(config) - GRID))}`;
function boardHalfWidth(boardOrConfig) {
const config = boardOrConfig?.type === "circuit_board" ? ensureConfig(boardOrConfig) : (boardOrConfig || defaultConfig());
return 64 * safeCols(config.cols) / 20;
}
function boardHalfHeight(boardOrConfig) {
const config = boardOrConfig?.type === "circuit_board" ? ensureConfig(boardOrConfig) : (boardOrConfig || defaultConfig());
return 64 * safeRows(config.rows) / 20;
}
function updateBoardPhysicalSize(board) {
if (!board || board.type !== "circuit_board") return;
const hw = boardHalfWidth(board.circuitConfig || defaultConfig());
const hh = boardHalfHeight(board.circuitConfig || defaultConfig());
board.r = Math.max(18, Math.hypot(hw, hh));
board.radius = board.r;
}
function parsePointToken(config, token) {
const match = /^p:(-?\d+):(-?\d+)$/.exec(String(token || ""));
if (!match) return null;
return {
x: clamp(snap(match[1]), GRID, canvasWidth(config) - GRID),
y: clamp(snap(match[2]), GRID, canvasHeight(config) - GRID),
};
}
function spacedOffsets(config, side, count) {
const extent = sideExtent(config, side);
const usable = Math.max(GRID, extent - GRID * 2);
const total = Math.max(1, count);
if (total === 1) return [safeOffset(config, side, extent * 0.5)];
return Array.from({ length: total }, (_, index) => safeOffset(config, side, GRID + usable * index / (total - 1)));
}
function defaultPorts(config, direction) {
const count = direction === "input" ? 3 : 2;
const side = direction === "input" ? "left" : "right";
const prefix = direction === "input" ? "I" : "O";
return spacedOffsets(config, side, Math.min(count, portLimit(config))).map((offset, index) => ({ id: `${prefix}${index + 1}`, side, offset }));
}
function defaultConfig() {
const config = { v: 4, cols: DEFAULT_COLS, rows: DEFAULT_ROWS, inputs: [], outputs: [], nodes: [], wires: [] };
config.inputs = defaultPorts(config, "input");
config.outputs = defaultPorts(config, "output");
return config;
}
function normalizeNodeId(value, used, index) {
let id = /^[a-z0-9_-]{1,24}$/i.test(String(value || "")) ? String(value) : `g${index + 1}`;
if (!used.has(id)) { used.add(id); return id; }
let suffix = 2;
while (used.has(`${id}_${suffix}`)) suffix += 1;
id = `${id}_${suffix}`;
used.add(id);
return id;
}
function normalizePorts(config, rawPorts, direction) {
const source = Array.isArray(rawPorts) ? rawPorts : [];
const maximum = portLimit(config);
const prefix = direction === "input" ? "I" : "O";
const fallbackSide = direction === "input" ? "left" : "right";
const used = new Set();
const result = [];
for (let index = 0; index < source.length && result.length < maximum; index += 1) {
const raw = source[index] || {};
let id = /^[A-Za-z][A-Za-z0-9_-]{0,11}$/.test(String(raw.id || "")) ? String(raw.id) : `${prefix}${index + 1}`;
if (used.has(id)) {
let n = 1;
while (used.has(`${prefix}${n}`)) n += 1;
id = `${prefix}${n}`;
}
used.add(id);
const side = safeSide(raw.side || fallbackSide);
result.push({ id, side, offset: safeOffset(config, side, raw.offset ?? ((index + 1) * GRID * 2)) });
}
return result;
}
function rotateOffset(x, y, rotation) {
const r = safeRotation(rotation);
if (r === 1) return { x: -y, y: x };
if (r === 2) return { x: -x, y: -y };
if (r === 3) return { x: y, y: -x };
return { x, y };
}
function nodeBodySize(type) {
if (type === "not" || type === "rectifier") return { width: 44, height: 30 };
if (type === "on") return { width: 38, height: 38 };
return { width: 80, height: 80 };
}
function nodePlacementBounds(config, type, rotation = 0) {
const size = nodeBodySize(type);
const r = safeRotation(rotation);
const bodyHalfW = (r % 2) ? size.height * 0.5 : size.width * 0.5;
const bodyHalfH = (r % 2) ? size.width * 0.5 : size.height * 0.5;
const probe = { id: "__probe__", type, x: 0, y: 0, rotation: r };
const pins = nodePins(probe);
let minPinX = 0, maxPinX = 0, minPinY = 0, maxPinY = 0;
for (const pin of pins) {
minPinX = Math.min(minPinX, pin.x); maxPinX = Math.max(maxPinX, pin.x);
minPinY = Math.min(minPinY, pin.y); maxPinY = Math.max(maxPinY, pin.y);
}
const width = canvasWidth(config), height = canvasHeight(config);
const minX = Math.max(bodyHalfW, GRID - minPinX);
const maxX = Math.min(width - bodyHalfW, width - GRID - maxPinX);
const minY = Math.max(bodyHalfH, GRID - minPinY);
const maxY = Math.min(height - bodyHalfH, height - GRID - maxPinY);
return {
minX: Math.ceil(minX / GRID) * GRID,
maxX: Math.floor(maxX / GRID) * GRID,
minY: Math.ceil(minY / GRID) * GRID,
maxY: Math.floor(maxY / GRID) * GRID,
};
}
function clampNodePosition(config, type, rotation, x, y) {
const bounds = nodePlacementBounds(config, type, rotation);
return {
x: clamp(snap(x), bounds.minX, Math.max(bounds.minX, bounds.maxX)),
y: clamp(snap(y), bounds.minY, Math.max(bounds.minY, bounds.maxY)),
};
}
function canvasPortPosition(config, port) {
const side = safeSide(port?.side);
const offset = safeOffset(config, side, port?.offset ?? sideExtent(config, side) * 0.5);
if (side === "left") return { x: GRID, y: offset };
if (side === "right") return { x: canvasWidth(config) - GRID, y: offset };
if (side === "top") return { x: offset, y: GRID };
return { x: offset, y: canvasHeight(config) - GRID };
}
function externalPins(config) {
return [
...config.inputs.map(port => ({ token: `i:${port.id}`, role: "source", direction: "input", kind: "external", label: port.id, port, ...canvasPortPosition(config, port) })),
...config.outputs.map(port => ({ token: `o:${port.id}`, role: "target", direction: "output", kind: "external", label: port.id, port, ...canvasPortPosition(config, port) })),
];
}
function nodePins(node) {
const pins = [];
const count = nodeInputCount(node.type);
const pushPin = (pin, role, ox, oy) => {
const rotated = rotateOffset(ox, oy, node.rotation);
pins.push({ token: `n:${node.id}:${pin}`, role, kind: "node", node, pin, x: snap(node.x + rotated.x), y: snap(node.y + rotated.y) });
};
if (count === 1) pushPin("a", "target", -GRID, 0);
if (count === 2) {
pushPin("a", "target", -GRID, -GRID);
pushPin("b", "target", -GRID, GRID);
}
pushPin("o", "source", GRID, 0);
return pins;
}
function allPins(config) {
return [...externalPins(config), ...config.nodes.flatMap(nodePins)];
}
function pinByToken(config, token) {
return allPins(config).find(pin => pin.token === token) || null;
}
function endpointPosition(config, token) {
const pin = pinByToken(config, token);
if (pin) return { x: pin.x, y: pin.y, pin };
const point = parsePointToken(config, token);
return point ? { ...point, pin: null } : null;
}
function validEndpoint(config, token) {
return Boolean(endpointPosition(config, token));
}
function wireKey(a, b) {
return a < b ? `${a}|${b}` : `${b}|${a}`;
}
function remapPointTokens(config, wires) {
return (wires || []).map(wire => {
const map = token => {
const match = /^p:(-?\d+):(-?\d+)$/.exec(String(token || ""));
return match ? pointToken(config, Number(match[1]), Number(match[2])) : String(token || "");
};
return { a: map(wire?.a), b: map(wire?.b) };
});
}
function normalizeConfig(value) {
// Save compatibility is intentionally not provided for older board formats.
if (!value || typeof value !== "object" || Number(value.v) !== 4) return defaultConfig();
const config = {
v: 4,
cols: safeCols(value.cols),
rows: safeRows(value.rows),
inputs: [],
outputs: [],
nodes: [],
wires: [],
};
config.inputs = normalizePorts(config, value.inputs, "input");
config.outputs = normalizePorts(config, value.outputs, "output");
const used = new Set();
config.nodes = (Array.isArray(value.nodes) ? value.nodes : []).slice(0, MAX_NODES).map((node, index) => {
const type = nodeType(node?.type);
const rotation = safeRotation(node?.rotation);
const pos = clampNodePosition(config, type, rotation, node?.x ?? (GRID * 3 + (index % 4) * GRID * 2), node?.y ?? (GRID * 2 + Math.floor(index / 4) * GRID * 2));
return { id: normalizeNodeId(node?.id, used, index), type, x: pos.x, y: pos.y, rotation };
});
const seen = new Set();
for (const wire of remapPointTokens(config, Array.isArray(value.wires) ? value.wires : []).slice(0, MAX_WIRES)) {
const a = String(wire?.a || "");
const b = String(wire?.b || "");
if (!validEndpoint(config, a) || !validEndpoint(config, b) || a === b) continue;
const key = wireKey(a, b);
if (seen.has(key)) continue;
seen.add(key);
config.wires.push({ a, b });
}
return config;
}
function ensureConfig(board) {
if (!board) return defaultConfig();
board.circuitConfig = normalizeConfig(board.circuitConfig);
updateBoardPhysicalSize(board);
return board.circuitConfig;
}
function serializeConfig(boardOrConfig) {
const value = boardOrConfig?.type === "circuit_board" ? ensureConfig(boardOrConfig) : normalizeConfig(boardOrConfig);
return clone(value) || defaultConfig();
}
function applySerializedConfig(board, value) {
if (!board) return false;
board.circuitConfig = normalizeConfig(value);
board._circuitTerminals = null;
board._circuitInputState = Object.create(null);
board._circuitNodeState = Object.create(null);
board._circuitOutputState = Object.create(null);
updateBoardPhysicalSize(board);
return true;
}
function ports(board) {
const config = ensureConfig(board);
return [
...config.inputs.map(port => ({ ...port, direction: "input", portId: `i:${port.id}`, label: `\u5165\u529b ${port.id}` })),
...config.outputs.map(port => ({ ...port, direction: "output", portId: `o:${port.id}`, label: `\u51fa\u529b ${port.id}` })),
];
}
function localPortPosition(port, boardOrConfig = null) {
const config = boardOrConfig?.type === "circuit_board" ? ensureConfig(boardOrConfig) : (boardOrConfig || defaultConfig());
const side = safeSide(port?.side);
const extent = sideExtent(config, side);
const offset = safeOffset(config, side, port?.offset ?? extent * 0.5);
const t = clamp(offset / Math.max(1, extent), 0, 1);
const halfW = boardHalfWidth(config);
const halfH = boardHalfHeight(config);
if (side === "left") return { x: -halfW, y: -halfH + t * halfH * 2 };
if (side === "right") return { x: halfW, y: -halfH + t * halfH * 2 };
if (side === "top") return { x: -halfW + t * halfW * 2, y: -halfH };
return { x: -halfW + t * halfW * 2, y: halfH };
}
function portById(board, portId) {
return ports(board).find(port => port.portId === portId) || null;
}
function portWorld(board, portOrId) {
const port = typeof portOrId === "string" ? portById(board, portOrId) : portOrId;
if (!board || !port) return null;
const local = localPortPosition(port, board);
const angle = Number(board.angle || 0) || 0;
const c = Math.cos(angle), s = Math.sin(angle);
return { x: board.x + local.x * c - local.y * s, y: board.y + local.x * s + local.y * c, localX: local.x, localY: local.y, port };
}
function nearestPort(board, x, y, maxDistance = 25) {
let best = null;
let bestD = Number(maxDistance) || 25;
for (const port of ports(board)) {
const point = portWorld(board, port);
const d = Math.hypot((Number(x) || 0) - point.x, (Number(y) || 0) - point.y);
if (d <= bestD) { bestD = d; best = { ...point, distance: d }; }
}
return best;
}
function isPortExternallyConnected(board, portId, worldRef = board?.world || global.world) {
if (!board || !portId || !worldRef?.items) return false;
for (const link of worldRef.items) {
if (!link || link.dead || !["wire", "insulated_wire"].includes(link.type)) continue;
for (let index = 0; index < 2; index += 1) {
const endpoint = global.TarinaiPhysicsBodySystem?.endpoint?.(link, index) || link.physicsConstraint?.endpoints?.[index] || null;
if (endpoint?.id === board.id && endpoint?.portId === portId) return true;
}
}
return false;
}
function remapAttachedWireEndpoints(board, worldRef = board?.world || global.world) {
if (!board || !worldRef?.items) return 0;
let changed = 0;
for (const link of worldRef.items) {
if (!link || link.dead || !["wire", "insulated_wire"].includes(link.type)) continue;
for (let index = 0; index < 2; index += 1) {
const endpoint = global.TarinaiPhysicsBodySystem?.endpoint?.(link, index) || link.physicsConstraint?.endpoints?.[index] || null;
if (!endpoint || endpoint.id !== board.id || !endpoint.portId) continue;
const point = portWorld(board, endpoint.portId);
if (!point) continue;
endpoint.localX = point.localX;
endpoint.localY = point.localY;
endpoint.x = point.x;
endpoint.y = point.y;
endpoint.center = false;
changed += 1;
}
}
const pending = worldRef.pendingLinkEndpoint;
if (pending?.id === board.id && pending.portId) {
const point = portWorld(board, pending.portId);
if (point) {
pending.localX = point.localX;
pending.localY = point.localY;
pending.x = point.x;
pending.y = point.y;
pending.center = false;
changed += 1;
}
}
if (changed) worldRef.markSpatialDirty?.("circuit-board-port-remap");
return changed;
}
function terminal(board, portId) {
if (!board) return null;
const port = portById(board, portId);
if (!port) return null;
if (!board._circuitTerminals || typeof board._circuitTerminals !== "object") board._circuitTerminals = Object.create(null);
let result = board._circuitTerminals[portId];
if (!result) result = board._circuitTerminals[portId] = { kind: "circuit_terminal", type: "circuit_terminal", board, boardId: board.id || "", portId, direction: port.direction, signalActive: false };
result.board = board;
result.boardId = board.id || "";
result.direction = port.direction;
result.signalActive = port.direction === "output" ? Boolean(board._circuitOutputState?.[port.id]) : Boolean(board._circuitInputState?.[port.id]);
return result;
}
function evaluate(board, inputValues = null) {
const config = ensureConfig(board);
const inputs = Object.create(null);
for (const port of config.inputs) inputs[port.id] = Boolean(inputValues ? inputValues[port.id] : board?._circuitInputState?.[port.id]);
const parent = new Map();
const rank = new Map();
const add = token => { if (!parent.has(token)) { parent.set(token, token); rank.set(token, 0); } };
const find = token => {
add(token);
let root = token;
while (parent.get(root) !== root) root = parent.get(root);
let current = token;
while (parent.get(current) !== current) { const next = parent.get(current); parent.set(current, root); current = next; }
return root;
};
const union = (a, b) => {
let ra = find(a), rb = find(b);
if (ra === rb) return;
const ar = rank.get(ra) || 0, br = rank.get(rb) || 0;
if (ar < br) { const t = ra; ra = rb; rb = t; }
parent.set(rb, ra);
if (ar === br) rank.set(ra, ar + 1);
};
for (const pin of allPins(config)) add(pin.token);
for (const wire of config.wires) { add(wire.a); add(wire.b); union(wire.a, wire.b); }
const members = new Map();
for (const token of parent.keys()) {
const root = find(token);
if (!members.has(root)) members.set(root, []);
members.get(root).push(token);
}
const nodeMap = new Map(config.nodes.map(node => [node.id, node]));
const dependencies = new Map(config.nodes.map(node => [node.id, new Set()]));
for (const node of config.nodes) {
for (const pinName of nodeInputCount(node.type) === 2 ? ["a", "b"] : (nodeInputCount(node.type) === 1 ? ["a"] : [])) {
const root = find(`n:${node.id}:${pinName}`);
for (const member of members.get(root) || []) {
const match = /^n:([^:]+):o$/.exec(member);
if (match && dependencies.has(match[1])) dependencies.get(node.id).add(match[1]);
}
}
}
const unstableNodes = new Set();
{
let index = 0;
const indices = new Map(), low = new Map(), stack = [], onStack = new Set();
const visit = id => {
indices.set(id, index); low.set(id, index); index += 1; stack.push(id); onStack.add(id);
for (const dep of dependencies.get(id) || []) {
if (!indices.has(dep)) { visit(dep); low.set(id, Math.min(low.get(id), low.get(dep))); }
else if (onStack.has(dep)) low.set(id, Math.min(low.get(id), indices.get(dep)));
}
if (low.get(id) !== indices.get(id)) return;
const component = [];
while (stack.length) {
const value = stack.pop(); onStack.delete(value); component.push(value);
if (value === id) break;
}
if (component.length > 1 || (component.length === 1 && dependencies.get(component[0])?.has(component[0]))) {
for (const value of component) unstableNodes.add(value);
}
};
for (const node of config.nodes) if (!indices.has(node.id)) visit(node.id);
}
const nodeValues = Object.create(null);
const netValues = new Map();
const visitingNodes = new Set();
const visitingNets = new Set();
function driverValue(token) {
if (token.startsWith("i:")) return Boolean(inputs[token.slice(2)]);
const match = /^n:([^:]+):o$/.exec(token);
return match ? nodeValue(match[1]) : false;
}
function netValue(token) {
const root = find(token);
if (netValues.has(root)) return Boolean(netValues.get(root));
if (visitingNets.has(root)) return false;
visitingNets.add(root);
let value = false;
for (const member of members.get(root) || [token]) {
if (member.startsWith("i:") || /^n:[^:]+:o$/.test(member)) {
if (driverValue(member)) { value = true; break; }
}
}
visitingNets.delete(root);
netValues.set(root, Boolean(value));
return Boolean(value);
}
function nodeValue(id) {
if (Object.prototype.hasOwnProperty.call(nodeValues, id)) return Boolean(nodeValues[id]);
if (visitingNodes.has(id)) return false;
const node = nodeMap.get(id);
if (!node) return false;
if (unstableNodes.has(id)) { nodeValues[id] = false; return false; }
visitingNodes.add(id);
let value = false;
if (node.type === "on") value = true;
else {
const a = netValue(`n:${id}:a`);
if (node.type === "not") value = !a;
else if (node.type === "rectifier") value = a;
else value = a && netValue(`n:${id}:b`);
}
visitingNodes.delete(id);
nodeValues[id] = Boolean(value);
return Boolean(value);
}
const outputs = Object.create(null);
for (const port of config.outputs) outputs[port.id] = netValue(`o:${port.id}`);
if (board) {
board._circuitInputState = inputs;
board._circuitNodeState = nodeValues;
board._circuitOutputState = outputs;
for (const port of config.outputs) {
const term = terminal(board, `o:${port.id}`);
if (term) term.signalActive = Boolean(outputs[port.id]);
}
}
return { inputs, nodes: nodeValues, outputs };
}
function nearestPin(config, x, y, maxDistance = 17) {
let best = null;
let bestD = maxDistance;
for (const pin of allPins(config)) {
const d = Math.hypot(x - pin.x, y - pin.y);
if (d <= bestD) { bestD = d; best = { ...pin, distance: d }; }
}
return best;
}
function junctionTokens(config) {
const degree = new Map();
for (const wire of config.wires) {
for (const token of [wire.a, wire.b]) if (parsePointToken(config, token)) degree.set(token, (degree.get(token) || 0) + 1);
}
return [...degree.entries()].map(([token, count]) => ({ token, count, ...parsePointToken(config, token) }));
}
function nearestJunction(config, x, y, maxDistance = 16) {
let best = null;
let bestD = maxDistance;
for (const junction of junctionTokens(config)) {
const d = Math.hypot(x - junction.x, y - junction.y);
if (d <= bestD) { bestD = d; best = { ...junction, distance: d }; }
}
return best;
}
function nodeAt(config, x, y) {
for (let i = config.nodes.length - 1; i >= 0; i -= 1) {
const node = config.nodes[i];
const rotation = safeRotation(node.rotation);
const dx = x - node.x, dy = y - node.y;
const local = rotateOffset(dx, dy, (4 - rotation) % 4);
const size = nodeBodySize(node.type);
if (Math.abs(local.x) <= size.width * 0.5 && Math.abs(local.y) <= size.height * 0.5) return node;
}
return null;
}
const distanceToSegment = (px, py, ax, ay, bx, by) => global.TarinaiGeometry.pointSegmentDistance(px, py, ax, ay, bx, by);
function wirePath(config, wire) {
const a = endpointPosition(config, wire.a);
const b = endpointPosition(config, wire.b);
return a && b ? [a, b] : [];
}
function wireAt(config, x, y, maxDistance = 9) {
let best = null;
let bestD = maxDistance;
for (let i = config.wires.length - 1; i >= 0; i -= 1) {
const path = wirePath(config, config.wires[i]);
if (path.length < 2) continue;
const d = distanceToSegment(x, y, path[0].x, path[0].y, path[1].x, path[1].y);
if (d <= bestD) { bestD = d; best = { wire: config.wires[i], index: i, distance: d }; }
}
return best;
}
function gcd(a, b) {
a = Math.abs(Math.round(a)); b = Math.abs(Math.round(b));
while (b) { const t = a % b; a = b; b = t; }
return a || 1;
}
function nearestGridPointOnWire(config, wire, x, y, maxDistance = 18) {
const path = wirePath(config, wire);
if (path.length < 2) return null;
const a = path[0], b = path[1];
const ux = Math.round((b.x - a.x) / GRID), uy = Math.round((b.y - a.y) / GRID);
const steps = gcd(ux, uy);
let best = null, bestD = maxDistance;
for (let i = 0; i <= steps; i += 1) {
const px = snap(a.x + (b.x - a.x) * i / steps);
const py = snap(a.y + (b.y - a.y) * i / steps);
const d = Math.hypot(x - px, y - py);
if (d <= bestD) { bestD = d; best = { x: px, y: py, distance: d }; }
}
return best;
}
function pointOnSegment(point, a, b) {
return distanceToSegment(point.x, point.y, a.x, a.y, b.x, b.y) < 0.6
&& point.x >= Math.min(a.x, b.x) - 0.6 && point.x <= Math.max(a.x, b.x) + 0.6
&& point.y >= Math.min(a.y, b.y) - 0.6 && point.y <= Math.max(a.y, b.y) + 0.6;
}
function splitWiresAtPoint(config, x, y) {
const token = pointToken(config, x, y);
const point = parsePointToken(config, token);
const next = [];
let changed = false;
for (const wire of config.wires) {
const path = wirePath(config, wire);
if (path.length < 2 || !pointOnSegment(point, path[0], path[1]) || wire.a === token || wire.b === token) {
next.push(wire); continue;
}
const aPos = endpointPosition(config, wire.a), bPos = endpointPosition(config, wire.b);
if ((aPos.x === point.x && aPos.y === point.y) || (bPos.x === point.x && bPos.y === point.y)) { next.push(wire); continue; }
next.push({ a: wire.a, b: token }, { a: token, b: wire.b });
changed = true;
}
if (changed) config.wires = next.slice(0, MAX_WIRES);
return token;
}
function moveJunction(config, oldToken, x, y) {
const nextToken = pointToken(config, x, y);
if (!parsePointToken(config, oldToken) || nextToken === oldToken) return nextToken;
for (const wire of config.wires) {
if (wire.a === oldToken) wire.a = nextToken;
if (wire.b === oldToken) wire.b = nextToken;
}
const seen = new Set();
config.wires = config.wires.filter(wire => {
if (wire.a === wire.b) return false;
const key = wireKey(wire.a, wire.b);
if (seen.has(key)) return false;
seen.add(key);
return true;
});
return nextToken;
}
function anchorAt(config, x, y, allowEmpty = false) {
const pin = nearestPin(config, x, y, 18);
if (pin) return { token: pin.token, x: pin.x, y: pin.y, kind: "pin" };
const junction = nearestJunction(config, x, y, 18);
if (junction) return { token: junction.token, x: junction.x, y: junction.y, kind: "junction" };
let best = null;
for (const wire of config.wires) {
const candidate = nearestGridPointOnWire(config, wire, x, y, 18);
if (candidate && (!best || candidate.distance < best.distance)) best = { ...candidate, wire };
}
if (best) return { token: pointToken(config, best.x, best.y), x: best.x, y: best.y, kind: "wire" };
if (!allowEmpty) return null;
const px = clamp(snap(x), GRID, canvasWidth(config) - GRID), py = clamp(snap(y), GRID, canvasHeight(config) - GRID);
return { token: pointToken(config, px, py), x: px, y: py, kind: "empty" };
}
function canvasPoint(canvas, event) {
const rect = canvas.getBoundingClientRect();
return { x: (event.clientX - rect.left) * canvas.width / Math.max(1, rect.width), y: (event.clientY - rect.top) * canvas.height / Math.max(1, rect.height) };
}
function terminalPlacement(config, x, y) {
const width = canvasWidth(config), height = canvasHeight(config);
const distances = [
{ side: "left", d: x },
{ side: "right", d: width - x },
{ side: "top", d: y },
{ side: "bottom", d: height - y },
].sort((a, b) => a.d - b.d);
const side = distances[0].side;
const axis = isVerticalSide(side) ? y : x;
return { side, offset: safeOffset(config, side, axis) };
}
function nextPortId(config, direction) {
const prefix = direction === "input" ? "I" : "O";
const portsList = direction === "input" ? config.inputs : config.outputs;
const used = new Set(portsList.map(port => port.id));
let index = 1;
while (used.has(`${prefix}${index}`)) index += 1;
return `${prefix}${index}`;
}
function ensureDialog() {
if (typeof document === "undefined") return null;
let dialog = document.getElementById("circuitBoardEditor");
if (dialog) return dialog;
dialog = document.createElement("div");
dialog.id = "circuitBoardEditor";
dialog.className = "rotator-editor circuit-grid-editor hidden";
dialog.innerHTML = `<div class="rotator-editor-panel circuit-grid-panel" role="dialog" aria-modal="true" aria-labelledby="circuitBoardTitle">
<div class="rotator-editor-titlebar"><h2 id="circuitBoardTitle">\u57fa\u76e4</h2><button class="rotator-editor-close" type="button" data-action="close" aria-label="\u9589\u3058\u308b">\u00d7</button></div>
<div class="circuit-port-count-controls">
<label>\u6a2a\u5e45 <button type="button" data-grid-delta="cols:-1">\u2212</button><input id="circuitGridCols" type="number" min="${MIN_COLS}" max="${MAX_COLS}" step="1"><button type="button" data-grid-delta="cols:1">\uff0b</button></label>
<label>\u7e26\u5e45 <button type="button" data-grid-delta="rows:-1">\u2212</button><input id="circuitGridRows" type="number" min="${MIN_ROWS}" max="${MAX_ROWS}" step="1"><button type="button" data-grid-delta="rows:1">\uff0b</button></label>
</div>
<div class="rotator-editor-tools circuit-grid-tools" role="toolbar" aria-label="\u56de\u8def\u7de8\u96c6">
<button type="button" data-circuit-tool="select">\u2196 \u79fb\u52d5</button>
<button type="button" data-circuit-tool="wire" class="active">\u2501 \u914d\u7dda</button>
<button type="button" data-circuit-tool="input_port">IN \u7aef\u5b50</button>
<button type="button" data-circuit-tool="output_port">OUT \u7aef\u5b50</button>
<button type="button" data-circuit-tool="and">AND</button>
<button type="button" data-circuit-tool="not">NOT</button>
<button type="button" data-circuit-tool="rectifier">\u6574\u6d41</button>
<button type="button" data-circuit-tool="on" title="\u5e38\u6642ON\u306e\u5b9a\u6570\u4fe1\u53f7\u3092\u51fa\u529b\u3057\u307e\u3059\u3002">ON</button>
<button type="button" data-circuit-tool="rotate">\u21bb \u56de\u8ee2</button>
<button type="button" data-circuit-tool="erase">\u232b \u6d88\u3057\u30b4\u30e0</button>
<button type="button" data-action="undo">\u21b6 \u4e00\u3064\u623b\u3059</button>
<button type="button" data-action="clear">\ud83d\uddd1 \u5168\u6d88\u3057</button>
</div>
<div class="circuit-grid-canvas-wrap"><canvas id="circuitGridCanvas" width="${CANVAS_W}" height="${CANVAS_H}" aria-label="\u57fa\u76e4\u306e\u56de\u8def\u7de8\u96c6"></canvas></div>
<p class="rotator-editor-hint circuit-grid-hint">IN/OUT\u7aef\u5b50\u30c4\u30fc\u30eb\u3067\u57fa\u76e4\u5916\u5468\u306b\u76f4\u63a5\u7aef\u5b50\u3092\u7f6e\u304d\u3001\u79fb\u52d5\u30c4\u30fc\u30eb\u3067\u56db\u8fba\u306e\u4efb\u610f\u4f4d\u7f6e\u3078\u79fb\u52d5\u3067\u304d\u307e\u3059\u3002\u6d88\u3057\u30b4\u30e0\u3067\u672a\u63a5\u7d9a\u7aef\u5b50\u3092\u524a\u9664\u3067\u304d\u307e\u3059\u3002\u914d\u7dda\u306f2\u70b9\u3092\u9078\u3093\u3067\u76f4\u7dda\u3067\u5f15\u304d\u3001\u65e2\u5b58\u7dda\u4e0a\u304b\u3089\u5206\u5c90\u3067\u304d\u307e\u3059\u3002NOT\u3092\u542b\u3080\u5faa\u74b0\u56de\u8def\u306f\u72b6\u614b\u304c\u5b89\u5b9a\u3057\u306a\u3044\u305f\u3081OFF\u3068\u3057\u3066\u6271\u3044\u307e\u3059\u3002\u56de\u8def\u90e8\u54c1\u306e\u8a2d\u7f6e\u524d\u306fQ/E\u306790\u5ea6\u305a\u3064\u56de\u8ee2\u3067\u304d\u307e\u3059\u3002ON\u306f\u5e38\u6642ON\u306e\u5b9a\u6570\u4fe1\u53f7\u6e90\u3067\u3059\u3002</p>
<div class="rotator-editor-actions"><button class="btn" type="button" data-action="reset">\u521d\u671f\u5316</button><button class="btn" type="button" data-action="cancel">\u30ad\u30e3\u30f3\u30bb\u30eb</button><button class="btn primary" type="button" data-action="save">\u53cd\u6620</button></div>
</div>`;
document.body.appendChild(dialog);
return dialog;
}
function openEditor(board, worldRef = global.world) {
if (!board || board.type !== "circuit_board") return false;
const dialog = ensureDialog();
const canvas = dialog?.querySelector("#circuitGridCanvas");
if (!dialog || !canvas) return false;
const ctx = canvas.getContext("2d");
let draft = serializeConfig(board);
let tool = "wire";
let wireDraft = null;
let drag = null;
let placementRotation = 0;
let hoverPoint = null;
const undo = [];
const colsInput = dialog.querySelector("#circuitGridCols");
const rowsInput = dialog.querySelector("#circuitGridRows");
const snapshot = () => {
undo.push(clone(draft));
if (undo.length > 40) undo.shift();
};
const restoreUndo = () => {
if (!undo.length) return;
draft = normalizeConfig(undo.pop());
wireDraft = null;
syncControls();
resizeCanvas();
draw();
};
const syncControls = () => {
if (colsInput) colsInput.value = String(draft.cols);
if (rowsInput) rowsInput.value = String(draft.rows);
};
const resizeCanvas = () => {
const width = canvasWidth(draft), height = canvasHeight(draft);
if (canvas.width !== width) canvas.width = width;
if (canvas.height !== height) canvas.height = height;
};
const setTool = next => {
const changedTool = tool !== next;
tool = next;
if (changedTool) placementRotation = 0;
wireDraft = null;
drag = null;
dialog.querySelectorAll("[data-circuit-tool]").forEach(button => button.classList.toggle("active", button.dataset.circuitTool === tool));
canvas.style.cursor = tool === "select" ? "grab" : (tool === "erase" ? "not-allowed" : "crosshair");
draw();
};
const drawGrid = () => {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.fillStyle = "#14231b"; ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.strokeStyle = "rgba(196,225,203,0.12)"; ctx.lineWidth = 1;
for (let x = 0; x <= canvas.width; x += GRID) { ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, canvas.height); ctx.stroke(); }
for (let y = 0; y <= canvas.height; y += GRID) { ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(canvas.width, y); ctx.stroke(); }
ctx.strokeStyle = "rgba(225,195,80,0.55)"; ctx.lineWidth = 2; ctx.strokeRect(1, 1, canvas.width - 2, canvas.height - 2);
};
const drawLine = (a, b, preview = false) => {
ctx.strokeStyle = preview ? "rgba(255,236,125,0.62)" : "rgba(223,192,71,0.94)";
ctx.lineWidth = preview ? 2 : 3;
if (preview) ctx.setLineDash([8, 6]);
ctx.beginPath(); ctx.moveTo(a.x, a.y); ctx.lineTo(b.x, b.y); ctx.stroke(); ctx.setLineDash([]);
};
const drawWire = wire => {
const path = wirePath(draft, wire);
if (path.length === 2) drawLine(path[0], path[1], false);
};
const drawNode = (node, options = {}) => {
const size = nodeBodySize(node.type);
const labels = { and: "AND", not: "NOT", rectifier: ">|", on: "ON" };
ctx.save();
if (options.preview) ctx.globalAlpha *= 0.48;
ctx.translate(node.x, node.y); ctx.rotate(safeRotation(node.rotation) * Math.PI * 0.5);
ctx.fillStyle = node.type === "on" ? "#765f27" : "#26352d";
ctx.strokeStyle = options.preview ? "rgba(255,236,125,0.96)" : "rgba(226,239,228,0.92)"; ctx.lineWidth = options.preview ? 3 : 2;
if (options.preview) ctx.setLineDash([7, 5]);
const x = -size.width * 0.5, y = -size.height * 0.5, r = Math.min(9, size.height * 0.3);
ctx.beginPath(); ctx.moveTo(x + r, y); ctx.lineTo(x + size.width - r, y); ctx.quadraticCurveTo(x + size.width, y, x + size.width, y + r); ctx.lineTo(x + size.width, y + size.height - r); ctx.quadraticCurveTo(x + size.width, y + size.height, x + size.width - r, y + size.height); ctx.lineTo(x + r, y + size.height); ctx.quadraticCurveTo(x, y + size.height, x, y + size.height - r); ctx.lineTo(x, y + r); ctx.quadraticCurveTo(x, y, x + r, y); ctx.closePath(); ctx.fill(); ctx.stroke();
ctx.fillStyle = "#f6fbf7"; ctx.font = `bold ${node.type === "and" ? 15 : 11}px ui-rounded, sans-serif`; ctx.textAlign = "center"; ctx.textBaseline = "middle";
ctx.fillText(labels[node.type] || "AND", 0, 0);
ctx.setLineDash([]);
ctx.restore();
};
const drawPlacementPreview = () => {
if (!hoverPoint) return;
if (GATE_TYPES.includes(tool)) {
const pos = clampNodePosition(draft, tool, placementRotation, hoverPoint.x, hoverPoint.y);
const ghost = { id: "__placement_preview__", type: tool, x: pos.x, y: pos.y, rotation: placementRotation };
drawNode(ghost, { preview: true });
ctx.save();
ctx.globalAlpha = 0.62;
for (const pin of nodePins(ghost)) {
ctx.fillStyle = pin.role === "source" ? "#68c8ff" : "#ff9a62";
if (tool === "on" && pin.role === "source") ctx.fillStyle = "#ffe45c";
ctx.strokeStyle = "rgba(8,22,14,0.95)"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(pin.x, pin.y, 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
ctx.restore();
return;
}
if (tool === "input_port" || tool === "output_port") {
const direction = tool === "input_port" ? "input" : "output";
const placement = nearestAvailablePortPlacement(direction, hoverPoint.x, hoverPoint.y);
if (!placement) return;
const pos = canvasPortPosition(draft, placement);
ctx.save(); ctx.globalAlpha = 0.58;
ctx.fillStyle = direction === "input" ? "#68c8ff" : "#ff9a62";
ctx.strokeStyle = "rgba(255,236,125,0.96)"; ctx.lineWidth = 3; ctx.setLineDash([6, 4]);
ctx.beginPath(); ctx.arc(pos.x, pos.y, 10, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
ctx.setLineDash([]); ctx.restore();
}
};
const drawPins = () => {
for (const pin of allPins(draft)) {
const external = pin.kind === "external";
const externalConnected = external ? isPortExternallyConnected(board, pin.token, worldRef) : true;
ctx.fillStyle = pin.role === "source" ? "#68c8ff" : "#ff9a62";
if (pin.node?.type === "on" && pin.role === "source") ctx.fillStyle = "#ffe45c";
if (external && !externalConnected) ctx.fillStyle = "#3d4842";
ctx.strokeStyle = external && !externalConnected ? "rgba(111,126,117,0.72)" : "rgba(8,22,14,0.95)"; ctx.lineWidth = 2;
ctx.beginPath(); ctx.arc(pin.x, pin.y, external ? 9 : 7, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
if (external) {
ctx.fillStyle = externalConnected ? "#f5fbf5" : "#748078"; ctx.font = "bold 13px ui-rounded, sans-serif"; ctx.textBaseline = "middle";
const side = pin.port?.side || "left";
ctx.textAlign = side === "left" ? "left" : (side === "right" ? "right" : "center");
const dx = side === "left" ? 14 : (side === "right" ? -14 : 0);
const dy = side === "top" ? 16 : (side === "bottom" ? -16 : 0);
ctx.fillText(pin.label, pin.x + dx, pin.y + dy);
}
}
for (const junction of junctionTokens(draft)) {
if (junction.count < 2) continue;
ctx.fillStyle = "#e8c440"; ctx.strokeStyle = "rgba(8,22,14,0.95)"; ctx.lineWidth = 1.5;
ctx.beginPath(); ctx.arc(junction.x, junction.y, junction.count >= 3 ? 7 : 5, 0, Math.PI * 2); ctx.fill(); ctx.stroke();
}
};
function draw() {
resizeCanvas();
drawGrid();
for (const wire of draft.wires) drawWire(wire);
for (const node of draft.nodes) drawNode(node);
drawPins();
drawPlacementPreview();
if (wireDraft) {
const start = endpointPosition(draft, wireDraft.startToken);
if (start) drawLine(start, wireDraft.preview, true);
}
}
function addNode(type, x, y, rotation = 0) {
if (draft.nodes.length >= MAX_NODES) { global.showToast?.("\u56de\u8def\u90e8\u54c1\u306f36\u500b\u307e\u3067\u3067\u3059\u3002"); return false; }
snapshot();
const id = `g${Date.now().toString(36).slice(-6)}${draft.nodes.length.toString(36)}`;
const safePlacementRotation = safeRotation(rotation);
const pos = clampNodePosition(draft, type, safePlacementRotation, x, y);
draft.nodes.push({ id, type, x: pos.x, y: pos.y, rotation: safePlacementRotation });
draft = normalizeConfig(draft); draw(); return true;
}
function addStraightConnection(startToken, endToken) {
if (!startToken || !endToken || startToken === endToken || draft.wires.length >= MAX_WIRES) return false;
const key = wireKey(startToken, endToken);
if (draft.wires.some(wire => wireKey(wire.a, wire.b) === key)) return false;
draft.wires.push({ a: startToken, b: endToken });
return true;
}
function eraseAt(x, y) {
const external = nearestPin(draft, x, y, 18);
if (external?.kind === "external") return removePort(external);
const junction = nearestJunction(draft, x, y, 13);
if (junction) {
snapshot();
draft.wires = draft.wires.filter(wire => wire.a !== junction.token && wire.b !== junction.token);
draw(); return true;
}
const node = nodeAt(draft, x, y);
if (node) {
snapshot();
draft.nodes = draft.nodes.filter(value => value.id !== node.id);
draft.wires = draft.wires.filter(wire => !wire.a.startsWith(`n:${node.id}:`) && !wire.b.startsWith(`n:${node.id}:`));
draw(); return true;
}
const hitWire = wireAt(draft, x, y, 10);
if (hitWire) { snapshot(); draft.wires.splice(hitWire.index, 1); draw(); return true; }
return false;
}
function portToken(direction, id) {
return `${direction === "input" ? "i" : "o"}:${id}`;
}
function portSlotKey(side, offset) {
return `${side}:${safeOffset(draft, side, offset)}`;
}
function nearestAvailablePortPlacement(direction, x, y, ignoreToken = "") {
const occupied = new Set();
for (const port of draft.inputs) if (`i:${port.id}` !== ignoreToken) occupied.add(portSlotKey(port.side, port.offset));
for (const port of draft.outputs) if (`o:${port.id}` !== ignoreToken) occupied.add(portSlotKey(port.side, port.offset));
const candidates = [];
for (const side of SIDES) {
for (let offset = GRID; offset <= sideExtent(draft, side) - GRID; offset += GRID) {
const key = portSlotKey(side, offset);
if (occupied.has(key)) continue;
const pos = canvasPortPosition(draft, { side, offset });
candidates.push({ side, offset, distance: Math.hypot(pos.x - x, pos.y - y) });
}
}
candidates.sort((a, b) => a.distance - b.distance);
return candidates[0] || null;
}
function addPort(direction, x, y) {
const list = direction === "input" ? draft.inputs : draft.outputs;
const limit = portLimit(draft);
if (list.length >= limit) {
global.showToast?.(`\u7e26\u5e45${draft.rows}\u30de\u30b9\u3067\u306f${direction === "input" ? "\u5165\u529b" : "\u51fa\u529b"}\u7aef\u5b50\u306f${limit}\u500b\u307e\u3067\u3067\u3059\u3002`);
return false;
}
const placement = nearestAvailablePortPlacement(direction, x, y);
if (!placement) { global.showToast?.("\u7aef\u5b50\u3092\u7f6e\u3051\u308b\u7a7a\u304d\u4f4d\u7f6e\u304c\u3042\u308a\u307e\u305b\u3093\u3002"); return false; }
snapshot();
list.push({ id: nextPortId(draft, direction), side: placement.side, offset: placement.offset });
draft = normalizeConfig(draft);
draw();
return true;
}
function removePort(pin) {
if (!pin?.kind || pin.kind !== "external") return false;
if (isPortExternallyConnected(board, pin.token, worldRef)) {
global.showToast?.("\u96fb\u7dda\u3092\u5916\u3057\u3066\u304b\u3089\u7aef\u5b50\u3092\u524a\u9664\u3057\u3066\u304f\u3060\u3055\u3044\u3002");
return false;
}
snapshot();
const list = pin.direction === "input" ? draft.inputs : draft.outputs;
const index = list.findIndex(port => port.id === pin.port?.id);
if (index < 0) return false;
const [removed] = list.splice(index, 1);
const token = portToken(pin.direction, removed.id);
draft.wires = draft.wires.filter(wire => wire.a !== token && wire.b !== token);
draft = normalizeConfig(draft);
draw();
return true;
}
function resizeGrid(cols, rows) {
const nextCols = safeCols(cols);
const nextRows = safeRows(rows);
const requiredRows = Math.max(draft.inputs.length, draft.outputs.length) + 1;
if (nextRows < requiredRows) {
global.showToast?.(`\u73fe\u5728\u306e\u7aef\u5b50\u6570\u3092\u4fdd\u3064\u306b\u306f\u7e26\u5e45${requiredRows}\u30de\u30b9\u4ee5\u4e0a\u304c\u5fc5\u8981\u3067\u3059\u3002`);
syncControls();
return;
}
if (nextCols === draft.cols && nextRows === draft.rows) return;
snapshot();
draft.cols = nextCols;
draft.rows = nextRows;
draft = normalizeConfig(draft);
syncControls();
resizeCanvas();
draw();
}
canvas.onpointerdown = event => {
const p = canvasPoint(canvas, event);
hoverPoint = p;
if (tool === "select") {
const external = nearestPin(draft, p.x, p.y, 20);
const junction = nearestJunction(draft, p.x, p.y, 15);
const node = nodeAt(draft, p.x, p.y);
if (external?.kind === "external") { snapshot(); drag = { kind: "port", token: external.token }; canvas.style.cursor = "grabbing"; return; }
if (junction) { snapshot(); drag = { kind: "junction", token: junction.token }; canvas.style.cursor = "grabbing"; return; }
if (node) { snapshot(); drag = { kind: "node", id: node.id, dx: p.x - node.x, dy: p.y - node.y }; canvas.style.cursor = "grabbing"; return; }
return;
}
if (tool === "erase") { eraseAt(p.x, p.y); return; }
if (tool === "rotate") {
const node = nodeAt(draft, p.x, p.y);
if (node) { snapshot(); node.rotation = safeRotation(node.rotation + 1); const pos = clampNodePosition(draft, node.type, node.rotation, node.x, node.y); node.x = pos.x; node.y = pos.y; draw(); }
return;
}
if (tool === "input_port") { addPort("input", p.x, p.y); return; }
if (tool === "output_port") { addPort("output", p.x, p.y); return; }
if (GATE_TYPES.includes(tool)) { addNode(tool, p.x, p.y, placementRotation); return; }
if (tool !== "wire") return;
if (!wireDraft) {
const anchor = anchorAt(draft, p.x, p.y, false);
if (!anchor) return;
snapshot();
if (anchor.kind === "wire") splitWiresAtPoint(draft, anchor.x, anchor.y);
wireDraft = { startToken: anchor.token, preview: { x: anchor.x, y: anchor.y } };
draw();
return;
}
const end = anchorAt(draft, p.x, p.y, true);
if (!end) { wireDraft = null; draw(); return; }
if (end.kind === "wire") splitWiresAtPoint(draft, end.x, end.y);
addStraightConnection(wireDraft.startToken, end.token);
wireDraft = null;
draft = normalizeConfig(draft);
draw();
};
canvas.onpointermove = event => {
const p = canvasPoint(canvas, event);
hoverPoint = p;
if (drag?.kind === "node") {
const node = draft.nodes.find(value => value.id === drag.id);
if (node) { const pos = clampNodePosition(draft, node.type, node.rotation, p.x - drag.dx, p.y - drag.dy); node.x = pos.x; node.y = pos.y; draw(); }
return;
}
if (drag?.kind === "junction") {
drag.token = moveJunction(draft, drag.token, p.x, p.y);
draw();
return;
}
if (drag?.kind === "port") {
const port = [...draft.inputs.map(value => ({ ...value, _direction: "input" })), ...draft.outputs.map(value => ({ ...value, _direction: "output" }))]
.find(value => `${value._direction === "input" ? "i" : "o"}:${value.id}` === drag.token);
const actual = port?._direction === "input" ? draft.inputs.find(value => value.id === port.id) : draft.outputs.find(value => value.id === port?.id);
if (actual) {
const next = nearestAvailablePortPlacement(port._direction, p.x, p.y, drag.token);
if (next) { actual.side = next.side; actual.offset = next.offset; draw(); }
}
return;
}
if (wireDraft) wireDraft.preview = { x: clamp(snap(p.x), GRID, canvas.width - GRID), y: clamp(snap(p.y), GRID, canvas.height - GRID) };
draw();
};
canvas.onpointerup = () => {
if (drag) { draft = normalizeConfig(draft); drag = null; canvas.style.cursor = "grab"; syncControls(); draw(); }
};
canvas.onpointercancel = () => { drag = null; wireDraft = null; draw(); };
canvas.onpointerleave = () => { hoverPoint = null; if (!drag) draw(); };
canvas.oncontextmenu = event => { event.preventDefault(); if (wireDraft) { wireDraft = null; draw(); } };
const handleEditorKeydown = event => {
const key = String(event.key || "").toLowerCase();
if ((key !== "q" && key !== "e") || !GATE_TYPES.includes(tool)) return;
const tag = String(event.target?.tagName || "").toLowerCase();
if (tag === "input" || tag === "textarea" || event.target?.isContentEditable) return;
event.preventDefault(); event.stopPropagation();
placementRotation = safeRotation(placementRotation + (key === "q" ? -1 : 1));
draw();
};
document.addEventListener("keydown", handleEditorKeydown, true);
dialog.querySelectorAll("[data-circuit-tool]").forEach(button => { button.onclick = () => setTool(button.dataset.circuitTool); });
dialog.querySelectorAll("[data-grid-delta]").forEach(button => {
button.onclick = () => {
const [axis, deltaRaw] = String(button.dataset.gridDelta || "").split(":");
const delta = Number(deltaRaw || 0);
resizeGrid(axis === "cols" ? draft.cols + delta : draft.cols, axis === "rows" ? draft.rows + delta : draft.rows);
};
});
if (colsInput) colsInput.onchange = () => resizeGrid(colsInput.value, draft.rows);
if (rowsInput) rowsInput.onchange = () => resizeGrid(draft.cols, rowsInput.value);
dialog.querySelector("[data-action=undo]").onclick = restoreUndo;
dialog.querySelector("[data-action=clear]").onclick = () => { snapshot(); draft.nodes = []; draft.wires = []; draw(); };
dialog.querySelector("[data-action=reset]").onclick = () => { snapshot(); draft = defaultConfig(); syncControls(); resizeCanvas(); draw(); };
const close = () => {
dialog.classList.add("hidden");
document.removeEventListener("keydown", handleEditorKeydown, true);
canvas.onpointerdown = null; canvas.onpointermove = null; canvas.onpointerup = null; canvas.onpointercancel = null; canvas.onpointerleave = null; canvas.oncontextmenu = null;
};
dialog.querySelector("[data-action=close]").onclick = close;
dialog.querySelector("[data-action=cancel]").onclick = close;
dialog.querySelector("[data-action=save]").onclick = () => {
applySerializedConfig(board, draft); evaluate(board); remapAttachedWireEndpoints(board, worldRef);
if (worldRef) { worldRef.drawListDirty = true; worldRef.markSpatialDirty?.("circuit-board-edited"); worldRef.log?.("\u57fa\u76e4\u306e\u56de\u8def\u3092\u7d44\u307f\u66ff\u3048\u305f\u3002", "observe"); }
global.showToast?.("\u57fa\u76e4\u306e\u56de\u8def\u3092\u53cd\u6620\u3057\u307e\u3057\u305f\u3002");
global.render?.(); close();
};
dialog.onclick = event => { if (event.target === dialog) close(); };
syncControls(); resizeCanvas(); setTool("wire"); dialog.classList.remove("hidden"); draw();
return true;
}
global.TarinaiCircuitBoardSystem = Object.freeze({
GATE_TYPES, HALF_W, HALF_H, CANVAS_W, CANVAS_H, GRID, DEFAULT_COLS, DEFAULT_ROWS, MIN_COLS, MAX_COLS, MIN_ROWS, MAX_ROWS,
defaultConfig, normalizeConfig, ensureConfig, serializeConfig, applySerializedConfig,
ports, portById, localPortPosition, portWorld, nearestPort, terminal, evaluate, openEditor,
allPins, pinByToken, wirePath, endpointPosition, boardHalfWidth, boardHalfHeight, portLimit, nodeBodySize, nodePlacementBounds, clampNodePosition, junctionTokens, moveJunction, remapAttachedWireEndpoints, isPortExternallyConnected,
});
})(typeof window !== "undefined" ? window : globalThis);