Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,3 +37,22 @@ jobs:
run: sudo apt-get install -y rpm
- name: Build & Package (Linux)
run: npx electron-vite build && npx electron-builder --linux --publish never

# Même build que release.yml (arm64, signature ad-hoc via after-pack.js),
# sans publication : vérifie juste que le packaging macOS ne casse pas.
build-macos:
runs-on: macos-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '20'
cache: 'npm'
- name: Install dependencies
run: npm install
- name: Download Python embed
run: node scripts/download-python-embed.js
- name: Build & Package (macOS arm64)
run: npx electron-vite build && npx electron-builder --mac --arm64 --publish never
env:
CSC_IDENTITY_AUTO_DISCOVERY: false
12 changes: 8 additions & 4 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ Alternatively, you can clone the repository and run the app directly without ins
launch.bat

# Linux / macOS
./launcher.sh
./launch.sh
```

---
Expand Down Expand Up @@ -108,6 +108,10 @@ Modly supports external model and process extensions. Each extension is a GitHub

---

## Workflows
Start with a basic workflow first. For example, on the "Workflows" tab, try: Image -> Generate Mesh -> Add to Scene. Make sure there is a connection between each of the steps. Go to the "Generate" tab, make sure the workflow is selected, then click on "Generate 3D Model". Click on "Settings/Logs/Errors" to see any issues.


## Modly CLI

Agents and scripts can call a running Modly desktop app without using the UI via the stdlib-only CLI. The CLI is a thin helper over Modly's canonical automation concepts and keeps final machine-readable JSON on stdout:
Expand Down Expand Up @@ -177,8 +181,8 @@ This is a requirement of the MIT license attribution clause. Please keep this cr

<a href="https://www.star-history.com/?repos=lightningpixel%2Fmodly&type=timeline&legend=top-left">
<picture>
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=lightningpixel/modly&type=timeline&theme=dark&legend=bottom-right" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=lightningpixel/modly&type=timeline&legend=bottom-right" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=lightningpixel/modly&type=timeline&legend=bottom-right" />
<source media="(prefers-color-scheme: dark)" srcset="https://api.star-history.com/chart?repos=lightningpixel/modly&type=timeline&theme=dark&legend=top-left&sealed_token=cOwK4zScWlXqsENFqBZjZxBegY236guAwYU7dQexei1zHYbRrqK7J5DgNqJ-826rONAWJq_iMt9w75AUW2VajCmscQbVaDifjUsqcJjc-wWVQg5SKLP6iHPv_d7Pyz2765Mjny2B3UjdFmWo-O4RKSaNvDy7m3A2BRQsFaXkEA3iyYYrO0cx6Uqv482L" />
<source media="(prefers-color-scheme: light)" srcset="https://api.star-history.com/chart?repos=lightningpixel/modly&type=timeline&legend=top-left&sealed_token=cOwK4zScWlXqsENFqBZjZxBegY236guAwYU7dQexei1zHYbRrqK7J5DgNqJ-826rONAWJq_iMt9w75AUW2VajCmscQbVaDifjUsqcJjc-wWVQg5SKLP6iHPv_d7Pyz2765Mjny2B3UjdFmWo-O4RKSaNvDy7m3A2BRQsFaXkEA3iyYYrO0cx6Uqv482L" />
<img alt="Star History Chart" src="https://api.star-history.com/chart?repos=lightningpixel/modly&type=timeline&legend=top-left&sealed_token=cOwK4zScWlXqsENFqBZjZxBegY236guAwYU7dQexei1zHYbRrqK7J5DgNqJ-826rONAWJq_iMt9w75AUW2VajCmscQbVaDifjUsqcJjc-wWVQg5SKLP6iHPv_d7Pyz2765Mjny2B3UjdFmWo-O4RKSaNvDy7m3A2BRQsFaXkEA3iyYYrO0cx6Uqv482L" />
</picture>
</a>
2 changes: 1 addition & 1 deletion api/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ def filter(self, record):

app = FastAPI(
title="Modly API",
version="0.4.0",
version="0.4.1",
lifespan=lifespan,
)

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "modly",
"version": "0.4.0",
"version": "0.4.1",
"description": "Local AI-powered 3D mesh generation from images",
"main": "./out/main/index.js",
"author": "Modly",
Expand Down
147 changes: 115 additions & 32 deletions src/areas/generate/GeneratePage.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { useState, useRef, useCallback, useEffect, useMemo } from 'react'
import type { ReactNode } from 'react'
import { useAppStore, DEFAULT_LIGHT_SETTINGS } from '@shared/stores/appStore'
import type { GenerationJob, LightSettings } from '@shared/stores/appStore'
import type { GenerationJob, LightSettings, PointLight } from '@shared/stores/appStore'
import { useApi } from '@shared/hooks/useApi'
import { ColorPicker } from '@shared/components/ui'
import GenerationHUD from './components/GenerationHUD'
Expand All @@ -27,6 +27,18 @@ const MIN_WIDTH = 220
const MAX_WIDTH = 520
const DEFAULT_WIDTH = 320

const MAX_POINT_LIGHTS = 6

function createPointLight(): PointLight {
const angle = Math.random() * Math.PI * 2
return {
id: crypto.randomUUID(),
position: [Math.cos(angle) * 1.5, 0.5, Math.sin(angle) * 1.5],
color: '#ffffff',
intensity: 1,
}
}

// ---------------------------------------------------------------------------
// Export dropdown
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -179,10 +191,16 @@ function LightPopover({
settings,
onChange,
onClose,
pointLights,
onPointLightsChange,
onSelectPointLight,
}: {
settings: LightSettings
onChange: (s: LightSettings) => void
onClose: () => void
pointLights: PointLight[]
onPointLightsChange: (lights: PointLight[]) => void
onSelectPointLight: (id: string | null) => void
}) {
function lightRow(
label: string,
Expand Down Expand Up @@ -251,6 +269,53 @@ function LightPopover({
{lightRow('Fill', 'fillColor', 'fillIntensity', 2)}
{plainRow('Ambient', 'ambientIntensity', 1.5)}
{plainRow('Environment', 'envIntensity', 2)}

{/* ── Point lights ── */}
<div className="border-t border-zinc-800 pt-2 mt-1 flex flex-col gap-2">
<div className="flex items-center justify-between">
<p className="text-[10px] text-zinc-500 uppercase tracking-wider">Point lights</p>
{pointLights.length < MAX_POINT_LIGHTS && (
<button
onClick={() => onPointLightsChange([...pointLights, createPointLight()])}
className="flex items-center gap-1 py-0.5 px-1.5 rounded text-[10px] text-zinc-500 hover:text-zinc-200 hover:bg-zinc-800 transition-colors"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="12" y1="5" x2="12" y2="19"/><line x1="5" y1="12" x2="19" y2="12"/>
</svg>
Add
</button>
)}
</div>

{pointLights.length === 0 && (
<p className="text-[10px] text-zinc-600 italic">No point lights yet.</p>
)}

{pointLights.map((pl) => (
<div key={pl.id} className="flex flex-col gap-1.5 p-2 rounded-lg bg-zinc-800/40 border border-zinc-700/40">
<div className="flex items-center gap-2">
<ColorPicker
value={pl.color}
onChange={(c) => onPointLightsChange(pointLights.map((p) => p.id === pl.id ? { ...p, color: c } : p))}
/>
<span className="text-[10px] text-zinc-400 flex-1">Point</span>
<span className="text-[10px] text-zinc-500 font-mono">{pl.intensity.toFixed(1)}</span>
<button
onClick={() => onPointLightsChange(pointLights.filter((p) => p.id !== pl.id))}
className="p-0.5 rounded text-zinc-600 hover:text-red-400 transition-colors"
>
<svg width="10" height="10" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
<line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/>
</svg>
</button>
</div>
<input type="range" min={0} max={16} step={0.1} value={pl.intensity}
onChange={(e) => onPointLightsChange(pointLights.map((p) => p.id === pl.id ? { ...p, intensity: parseFloat(e.target.value) } : p))}
className="w-full h-1.5 accent-violet-500 cursor-pointer" />
</div>
))}
</div>

<button
onClick={onClose}
className="mt-1 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200 bg-zinc-800 hover:bg-zinc-700 rounded-lg transition-colors"
Expand Down Expand Up @@ -577,11 +642,14 @@ export default function GeneratePage(): JSX.Element {
const [libraryCollapsedSectionKeys, setLibraryCollapsedSectionKeys] = useState<string[]>(() => getDefaultAssetLibraryCollapsedSectionKeys())
const [gizmoMode, setGizmoMode] = useState<'translate' | 'rotate' | 'scale' | null>(null)
const dragging = useRef(false)
const [selectedPointLightId, setSelectedPointLightId] = useState<string | null>(null)
// Populated by Viewer3D — undoes the latest live gizmo transform, if any.
const gizmoUndoRef = useRef<(() => boolean) | null>(null)

const lightSettings = useAppStore((s) => s.lightSettings)
const setLightSettings = useAppStore((s) => s.setLightSettings)
const pointLights = useAppStore((s) => s.pointLights)
const setPointLights = useAppStore((s) => s.setPointLights)
const isGenerating = useAppStore((s) =>
s.currentJob?.status === 'uploading' || s.currentJob?.status === 'generating'
)
Expand Down Expand Up @@ -612,27 +680,27 @@ export default function GeneratePage(): JSX.Element {

const hasModel = currentJob?.status === 'done' && !!currentJob.outputUrl

// Drop the active transform tool when the mesh is deselected, so it doesn't
// Drop the active transform tool when nothing is selected, so it doesn't
// silently re-activate on the next selection.
useEffect(() => {
if (!meshSelected) setGizmoMode(null)
}, [meshSelected])
if (!meshSelected && !selectedPointLightId) setGizmoMode(null)
}, [meshSelected, selectedPointLightId])

// Gizmo hotkeys: W move, R rotate, S scale, Esc exits. Ignored while typing.
useEffect(() => {
const handler = (e: KeyboardEvent) => {
const el = document.activeElement as HTMLElement | null
if (el && (el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement || el.isContentEditable)) return
if (e.key === 'Escape') { setGizmoMode((m) => (m ? null : m)); return }
if (!hasModel || !meshSelected) return
if (!hasModel || (!meshSelected && !selectedPointLightId)) return
const k = e.key.toLowerCase()
if (k === 'w') setGizmoMode('translate')
else if (k === 'r') setGizmoMode('rotate')
else if (k === 's') setGizmoMode('scale')
else if (k === 'r' && meshSelected) setGizmoMode('rotate')
else if (k === 's' && meshSelected) setGizmoMode('scale')
}
window.addEventListener('keydown', handler)
return () => window.removeEventListener('keydown', handler)
}, [hasModel, meshSelected])
}, [hasModel, meshSelected, selectedPointLightId])

useEffect(() => {
if (openPanel !== 'library' || libraryLoaded || libraryLoading) return
Expand Down Expand Up @@ -1073,14 +1141,17 @@ export default function GeneratePage(): JSX.Element {
settings={lightSettings}
onChange={setLightSettings}
onClose={() => setOpenPanel(null)}
pointLights={pointLights}
onPointLightsChange={setPointLights}
onSelectPointLight={setSelectedPointLightId}
/>
)}
</div>
</div>

{/* Tools bar — always visible; transform tools appear once a mesh is selected */}
<div className="flex items-center gap-2 px-3 h-10 border-b border-zinc-800 bg-surface-400 shrink-0">
{hasModel && meshSelected && (
{(meshSelected || selectedPointLightId) && (
<>
<ToolButton
label="Move"
Expand All @@ -1096,35 +1167,47 @@ export default function GeneratePage(): JSX.Element {
<line x1="12" y1="2" x2="12" y2="22" />
</svg>
</ToolButton>
<ToolButton
label="Rotate"
active={gizmoMode === 'rotate'}
onClick={() => setGizmoMode((m) => (m === 'rotate' ? null : 'rotate'))}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M21 2v6h-6" />
<path d="M21 13a9 9 0 1 1-3-7.7L21 8" />
</svg>
</ToolButton>
<ToolButton
label="Scale"
active={gizmoMode === 'scale'}
onClick={() => setGizmoMode((m) => (m === 'scale' ? null : 'scale'))}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M15 3h6v6" />
<path d="M9 21H3v-6" />
<path d="M21 3l-7 7" />
<path d="M3 21l7-7" />
</svg>
</ToolButton>
{meshSelected && (
<>
<ToolButton
label="Rotate"
active={gizmoMode === 'rotate'}
onClick={() => setGizmoMode((m) => (m === 'rotate' ? null : 'rotate'))}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M21 2v6h-6" />
<path d="M21 13a9 9 0 1 1-3-7.7L21 8" />
</svg>
</ToolButton>
<ToolButton
label="Scale"
active={gizmoMode === 'scale'}
onClick={() => setGizmoMode((m) => (m === 'scale' ? null : 'scale'))}
>
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.75">
<path d="M15 3h6v6" />
<path d="M9 21H3v-6" />
<path d="M21 3l-7 7" />
<path d="M3 21l7-7" />
</svg>
</ToolButton>
</>
)}
</>
)}
</div>

{/* Viewer area */}
<div className="flex-1 relative overflow-hidden">
<Viewer3D lightSettings={lightSettings} gizmoMode={gizmoMode} gizmoUndoRef={gizmoUndoRef} />
<Viewer3D
lightSettings={lightSettings}
gizmoMode={gizmoMode}
gizmoUndoRef={gizmoUndoRef}
pointLights={pointLights}
selectedPointLightId={selectedPointLightId}
onSelectPointLight={setSelectedPointLightId}
onPointLightsChange={setPointLights}
/>
<GenerationHUD />
</div>
</div>
Expand Down
Loading